Is Tailwind great? Yes. But does it have any weak points?
Using Tailwind offers numerous advantages:
- Speeds up development: It keeps content and presentation layers close, avoids the cognitive load of naming classes, and nearly eliminates style collisions.
- Enhances consistency: Through the use of tokens.
- Produces small CSS files: By extracting only the classes you use.
However, it has a clear downside compared to traditional CSS approaches: code repetition. To mitigate this, Tailwind’s documentation suggests using components wherever possible.
Another drawback: lost semantics
For instance, applying the class btn--lg
to an element implies using a “large” variant. However, using classes like px-6 py-4 text-lg
loses this context.
How to solve this with React?
One way to address this at the UI library level (e.g., in React) is to create a Button
component that accepts variants as props and maps those props to Tailwind classes.
javascriptCopiaModificaimport type { ReactNode } from 'react';
const sizeStyles = {
sm: 'px-3 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-4 text-lg',
};
const intentStyles = {
primary: 'bg-blue-600 text-blue-50',
secondary: 'bg-orange-600 text-orange-50',
default: 'text-slate-600 text-slate-50',
};
interface Props {
size: keyof typeof sizeStyles;
intent: keyof typeof intentStyles;
children: ReactNode;
}
export const Button = ({ size, intent, children }) => (
<button className={`inline-block border-0 ${sizeStyles[size]} ${intentStyles[intent]}`}>
{children}
</button>
);
Advantages of this approach:
- Reduces repetitive Tailwind classes: No need to write the same classes repeatedly for buttons.
- Improves clarity: You don’t have to remember that
primary
equalsbg-blue-600 text-blue-50
. - Centralizes styles: Updating styles is straightforward without needing to navigate multiple files.
But is this the final solution? Not quite.
This component has some limitations:
- It doesn’t allow passing an
onClick
event or other native props. - It can’t handle additional classes passed via
className
without overwriting its internal styles. - It doesn’t support working with refs (e.g., for focusing on the button imperatively).
- It’s not polymorphic—you can’t render other elements (like
<a>
or<div>
) with the same styles.
Optimizing the Code
The minimal code required to centralize button styles, keep semantic usage, and avoid duplication is:
javascriptCopiaModificaconst sizeStyles = {
sm: 'px-3 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-4 text-lg',
};
const intentStyles = {
primary: 'bg-blue-600 text-blue-50',
secondary: 'bg-orange-600 text-orange-50',
default: 'text-slate-600 text-slate-50',
};
export const button = ({ size, intent }: { size: keyof typeof sizeStyles; intent: keyof typeof intentStyles }) =>
`inline-block border-0 ${sizeStyles[size]} ${intentStyles[intent]}`;
To use it:
javascriptCopiaModificaclassName={button({ intent: 'primary', size: 'lg' })}
This approach avoids prop-drilling issues, is framework-agnostic, and can work in Vue, Svelte, or any framework.
Accelerating Development with CVA
CVA (class-variance-authority) is a JavaScript library for building CSS components based on variants. Here’s how you can use it to solve the button example:
javascriptCopiaModificaimport { cva } from 'class-variance-authority';
export const button = cva('inline-block border-0', {
variants: {
size: {
sm: 'px-3 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-4 text-lg',
},
intent: {
primary: 'bg-blue-600 text-blue-50',
secondary: 'bg-orange-600 text-orange-50',
default: 'text-slate-600 text-slate-50',
},
},
});
To invoke:
javascriptCopiaModificaclassName={button({ intent: 'primary', size: 'lg' })}
Extending Component Classes
To add additional classes to the component, use the class
property (or className
for React):
javascriptCopiaModificaclassName={button({
intent: 'primary',
size: 'lg',
class: 'text-blue-600',
})}
Extracting Types
You can extract types for component variants using VariantProps
:
javascriptCopiaModificaimport type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
export const button = cva(/* ... */);
export type ButtonProps = VariantProps<typeof button>;
Default Variants
Set default variants with defaultVariants
:
javascriptCopiaModificaexport const button = cva('inline-block border-0', {
variants: {
size: { sm: 'px-3 py-1 text-sm', md: 'px-4 py-2 text-base', lg: 'px-6 py-4 text-lg' },
intent: { primary: 'bg-blue-600 text-blue-50', secondary: 'bg-orange-600 text-orange-50', default: 'text-slate-600 text-slate-50' },
},
defaultVariants: { size: 'md', intent: 'default' },
});
Compound Variants
To define styles for multiple conditions:
javascriptCopiaModificaexport const button = cva('inline-block border-0', {
variants: {
size: { sm: 'px-3 py-1 text-sm border', md: 'px-4 py-2 text-base border-2', lg: 'px-6 py-4 text-lg border-4' },
outline: { false: 'border-transparent', true: null },
intent: { primary: null, secondary: null, default: null },
},
compoundVariants: [
{ intent: 'primary', outline: false, class: 'bg-blue-600 text-blue-50' },
{ intent: 'primary', outline: true, class: 'bg-blue-50 text-blue-600 border-blue-600' },
],
defaultVariants: { size: 'md', intent: 'default', outline: false },
});
Avoiding Class Collisions with Tailwind Merge
Use tailwind-merge
to resolve class conflicts:
javascriptCopiaModificaimport { twMerge } from 'tailwind-merge';
twMerge('p-3', 'p-4'); // Output: p-4
twMerge('block', 'flex'); // Output: flex
twMerge('p-3 hover:p-4', 'p-2'); // Output: p-2 hover:p-4
Conclusion
Building a UI component that meets functional requirements is not trivial, especially for purely presentational components. In such cases, CVA offers a practical and efficient alternative, with the added benefit of being framework-agnostic.