Tailwind with component libraries and variant helpers
Combine class lists safely with cn(), declare variants with cva, and integrate headless component libraries without losing ownership of the styling.
Merging class lists
npm install clsx tailwind-merge// lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}// the problem: both classes win or lose by stylesheet order, not by intent
<div class="p-4 p-8">...</div> // unpredictable
// with cn(), the last one wins as a real override
cn("p-4", "p-8"); // "p-8"
cn("p-4", condition && "p-8"); // "p-4" when false
cn("text-slate-600", props.className); // the caller's class wins| Tool | Does | Does not |
|---|---|---|
clsx | Joins truthy values into a string | Resolve conflicting utilities |
tailwind-merge | Removes earlier conflicting utilities | Evaluate conditions |
cn() | Both, in the right order | Understand arbitrary CSS |
cva | Declares variants as data | Merge a caller's override |
tailwind-variants | Declares variants, with slots | Replace a design system |
💡
tailwind-merge knows the utility groups, so it can replace p-4 with p-8 but leaves unrelated classes alone. It cannot resolve a conflict it does not recognise, which is the case for arbitrary properties and for custom utilities you have not taught it about.Declaring variants
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./cn";
const button = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition " +
"focus-visible:outline-2 focus-visible:outline-offset-2 " +
"disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-sky-600 text-white hover:bg-sky-500 focus-visible:outline-sky-600",
secondary: "border border-slate-300 bg-white text-slate-900 hover:bg-slate-50",
danger: "bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600",
ghost: "text-slate-700 hover:bg-slate-100",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-5 text-base",
},
},
defaultVariants: { variant: "primary", size: "md" },
}
);
type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof button>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(button({ variant, size }), className)} {...props} />;
}<Button>Default</Button>
<Button variant="danger" size="sm">Delete</Button>
<Button variant="ghost" className="w-full justify-start">Menu item</Button>- The base classes are declared once; a variant changes only what differs.
- The variant names become the component's API, which is what stops callers passing arbitrary padding.
classNameis merged last, so a caller can still override when they have a real reason.- Use a compound variant when two variants interact:
compoundVariants: [{ variant: 'ghost', size: 'lg', class: 'text-base' }]. - If the variant matrix has more than about a dozen combinations, the component is doing too much.
Headless libraries
// Radix gives behaviour and accessibility; you own every class
import * as Dialog from "@radix-ui/react-dialog";
<Dialog.Root>
<Dialog.Trigger className={cn(button({ variant: "secondary" }))}>
Open settings
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 data-[state=open]:animate-fade-in" />
<Dialog.Content className="fixed top-1/2 left-1/2 w-full max-w-md
-translate-x-1/2 -translate-y-1/2 rounded-xl
bg-white p-6 shadow-lg
data-[state=open]:animate-fade-in-up">
<Dialog.Title className="text-lg font-semibold">Settings</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-slate-600">
Changes apply immediately.
</Dialog.Description>
<Dialog.Close className="mt-4">Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>| Layer | Comes from | You own |
|---|---|---|
| Focus trap, Escape, scroll lock | Radix or Headless UI | Nothing |
| ARIA roles and relationships | The library | Keeping them intact |
| Markup structure | The library | Nothing |
| Every class | You | Everything visual |
| Variant API | You, via cva | The design system |
- Put the classes in your own wrapper component once, so the library's markup appears in exactly one file.
- Style the data attributes the library exposes -
data-state=open,data-disabled- rather than fighting its internal class names. - Keep the library's accessible names and roles. Removing the title because the design has no visible heading is the mistake that makes the component unusable with a screen reader.
- If a headless library only saves you a focus trap, take the dependency; a focus trap written by hand is almost always subtly wrong.
- Check what the library ships in its own stylesheet. A component library that imports its own CSS will fight your utilities on specificity.
Ownership test: could you swap the headless library for another one
without changing any of your product code? If yes, the boundary is
in the right place. If not, the library is your design system.FAQ
Do I need tailwind-merge if I control every component?
You need it wherever a caller can pass a class name - which is every reusable component. Without it, an override works only if the two utilities happen not to conflict, and the failure is silent and depends on stylesheet order.
Is cva worth it for a few components?
Yes, from the third component. The value is not the typing, it is that the variant matrix is written down in one place, which makes a missing combination visible and makes the component's API explicit rather than implied by its class list.
Related
Component patterns without a component library Migrating from v3 to v4
Last refreshed 2026-09-18.