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
ToolDoesDoes not
clsxJoins truthy values into a stringResolve conflicting utilities
tailwind-mergeRemoves earlier conflicting utilitiesEvaluate conditions
cn()Both, in the right orderUnderstand arbitrary CSS
cvaDeclares variants as dataMerge a caller's override
tailwind-variantsDeclares variants, with slotsReplace 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.
  • className is 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>
LayerComes fromYou own
Focus trap, Escape, scroll lockRadix or Headless UINothing
ARIA roles and relationshipsThe libraryKeeping them intact
Markup structureThe libraryNothing
Every classYouEverything visual
Variant APIYou, via cvaThe design system
  1. Put the classes in your own wrapper component once, so the library's markup appears in exactly one file.
  2. Style the data attributes the library exposes - data-state=open, data-disabled - rather than fighting its internal class names.
  3. 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.
  4. If a headless library only saves you a focus trap, take the dependency; a focus trap written by hand is almost always subtly wrong.
  5. 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.

Component patterns without a component library Migrating from v3 to v4

Last refreshed 2026-09-18.