
Scaling Tailwind CSS
How to make Tailwind CSS enjoyable to use at scale (or not)
It's 2025. If you haven't at least heard of Tailwind CSS, you're living under a rock. Tailwind CSS is a utility-first CSS framework that lets you style elements with classes.
When you use Tailwind, you write small, single-purpose classes directly in your markup. Instead of this:
1- <div class="acme-btn some__really__long__class__name">2- <h1 class="bold__this__text_because__its__a__button">Acme Button</h1>3- </div>
You'll write this:
1+ <div class="bg-primary text-white rounded-full px-4 py-2">2+ <h1 class="text-2xl font-bold">Acme Button</h1>3+ </div>
Here's what those classes actually compile to:
| Class | Compiled CSS |
|---|---|
| bg-primary | background-color: #000; |
| text-white | color: #fff; |
| rounded-full | border-radius: 9999px; |
| px-4 | padding-left: 1rem; padding-right: 1rem; |
| py-2 | padding-top: 0.5rem; padding-bottom: 0.5rem; |
| text-2xl | font-size: 1.5rem; line-height: 2rem; |
| font-bold | font-weight: 700; |
Tailwind also has a solid plugin system. The community has built plugins for aspect-ratio, animate, typography, and more. You can write your own too, but that's a different conversation that we shouldn't get into here.
The truth
Here's the thing: Tailwind is great, but you control how maintainable, composable, and readable your code becomes. The framework won't save you from messy class lists or inconsistent patterns.
Guardrails
The solution is setting up guardrails. When you're working with Tailwind on real projects, you need rules in place. Otherwise, someone (maybe you) will look at your code in six months and wonder what the hell happened.
These are your best friends. They automatically sort and group your classes. Nobody wants to scan through a jumbled mess of unordered classes.
Biome users, you're out of luck here. The linter support is only partial.
Obviously, the more classes you cram into one element, the harder it gets to read and maintain. Keep your class lists reasonable. Nobody wants to be this person:
Variants
To the common man, variants let you apply classes based on conditions. I call them fancy switch statements. I'm gonna cover how to work your way up the stack to complex variants.
Built-in Tailwind Variants
Tailwind ships with variants that handle most common scenarios. These are more than enough for a good amount of projects, especially as a solo dev.
Pseudo class variants
Hopefully, we all know these: hover:, focus:, active::
1+ <div class="hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary active:scale-95">2+ <h1 class="text-2xl font-bold">Acme Button</h1>3+ </div>
Responsive Variants
Sometimes on different viewports we want different styles:
1+ <div class="text-sm md:text-base lg:text-lg xl:text-xl">2+ <h1 class="text-2xl font-bold">Responsive Text</h1>3+ </div>
State Variants
Common states like disabled states:
1+ <div class="bg-gray-100 disabled:bg-gray-300 disabled:cursor-not-allowed">2+ <h1 class="text-2xl font-bold">Disabled</h1>3+ </div>
These work fine for simple cases, but for projects across an entire engineering organization, we (obviously) need more scalable approaches.
The cn() Utility
Before we get into complex libraries, there's one utility that's become essential in this weird, post-apocalyptic ai-slop world we're in. And I guarantee you'll see it in any project: cn().
It combines clsx and tailwind-merge into something really useful.
What?
You'll usually see this in your utils.ts file, especially in a shadcn project:
1import { clsx, type ClassValue } from 'clsx'2import { twMerge } from 'tailwind-merge'3
4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs))6}
Why?
This became the standard because it solves two legit problems teams face at scale:
- Conditional Classes:
clsxhandles conditional logic - Conflict Resolution:
tailwind-mergesorts out conflicting classes
Basic Usage
Here's how you'd use it:
1const Button = ({ variant, size, disabled, className, ...props }) => {2 return (3 <button4 className={cn(5 'font-semibold rounded transition-colors',6 {7 'bg-blue-500 text-white hover:bg-blue-600': variant === 'primary',8 'bg-gray-200 text-gray-800 hover:bg-gray-300': variant === 'secondary',9 'text-sm px-3 py-1': size === 'small',10 'text-base px-4 py-2': size === 'medium',11 'opacity-50 cursor-not-allowed': disabled,12 },13 className14 )}15 disabled={disabled}16 {...props}17 />18 )19}
Now, your CSS output is intelligently generated based on the props you pass in.
Conflict Resolution
The real power is tailwind-merge's conflict resolution:
1// without -> we hate this2<button className="px-2 py-1 p-4 bg-red-500 bg-blue-500" />3
4// with -> oh we love this5<button className={cn('px-2 py-1 p-4 bg-red-500 bg-blue-500')} />
Then your final result is p-4 bg-blue-500
When more is needed...
As you can kinda tell, cn() handles most cases, but sometimes we really do need more structure and type safety. That's where dedicated libraries come in.
Class Variance Authority (CVA)
For the more wicked systems, class-variance-authority (CVA) gives you that same structure we just covered, but with amazing TypeScript support:
1import { cva, type VariantProps } from 'class-variance-authority'2
3const buttonVariants = cva(4 'font-semibold border rounded',5 {6 variants: {7 intent: {8 primary: 'bg-blue-500 text-white border-transparent hover:bg-blue-600',9 secondary: 'bg-white text-gray-800 border-gray-400 hover:bg-gray-100'10 },11 size: {12 small: 'text-sm py-1 px-2',13 medium: 'text-base py-2 px-4'14 },15 disabled: {16 true: 'opacity-50 cursor-not-allowed',17 false: ''18 }19 },20 compoundVariants: [21 {22 intent: 'primary',23 disabled: false,24 class: 'uppercase'25 }26 ],27 defaultVariants: {28 intent: 'primary',29 size: 'medium',30 disabled: false31 }32 }33)34
35export type ButtonVariants = VariantProps<typeof buttonVariants>36
37const classes = buttonVariants({ intent: 'secondary', size: 'small' })
If you're building a component library, I think this is one of the absolute best options in the world. It's funny how projects like these don't get the recognition they deserve, but cheating software does.
Tailwind Variants ™
tailwind-variants by Hero UI brings a first-class variant API to Tailwind:
1import { tv } from 'tailwind-variants'2
3const button = tv({4 base: 'font-medium bg-blue-500 text-white rounded-full active:opacity-80',5 variants: {6 color: {7 primary: 'bg-blue-500 text-white',8 secondary: 'bg-purple-500 text-white'9 },10 size: {11 sm: 'text-sm',12 md: 'text-base',13 lg: 'px-4 py-3 text-lg'14 }15 },16 compoundVariants: [17 {18 size: ['sm', 'md'],19 class: 'px-3 py-1'20 }21 ],22 defaultVariants: {23 size: 'md',24 color: 'primary'25 }26})27
28const card = tv({29 slots: {30 base: 'border border-gray-200 rounded-lg p-4',31 title: 'text-lg font-semibold text-gray-900',32 description: 'text-gray-600'33 },34 variants: {35 shadow: {36 sm: { base: 'shadow-sm' },37 lg: { base: 'shadow-lg' }38 }39 }40})
The slot feature is what makes tailwind-variants epic. You can define multiple related elements in one component (like a card with title and description).
Last two notes
@apply
Please, use this sparingly. It's definitely a footgun for non-experienced developers. The ideal case for this utility is when you're building a component library and you need true design tokens/primitives (btn, heading), not for every little pattern you can think of.
Intellisense
The TailwindCSS IntelliSense extension for VSCode is a godsend, like no joke. It enhances pretty much every aspect of the development experience with Tailwind by providing stuff like autocomplete, hover previews, and so much more.