Skip to content

Tailwind Merge and clsx: Class Composition

Compose Tailwind classes with clsx and tailwind-merge, conflict resolution as policy, not string order luck.

5 min read
tailwind-merge
clsx
Tailwind CSS
Class Composition
CVA
Design System

CVA gives primitives a typed variant matrix. That matrix still ends in a string of classes, and strings lie when two utilities fight (px-3 vs px-4, bg-blue-600 vs bg-red-500).

clsx builds the list. tailwind-merge decides who wins. I treat them as composition policy, not as a reflex import on every file.

What each tool owns

clsx (or classnames): conditional joining. Booleans, arrays, objects. No Tailwind awareness, it will happily emit both px-3 and px-4.

tailwind-merge (twMerge): understands Tailwind group conflicts and keeps the last conflicting utility in the merged string (with configurable theme groups).

ts
// packages/ui/src/lib/cn.ts, illustrative helper import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
ts
cn('px-3 py-2', true && 'px-4', { 'opacity-50': disabled }); // → "py-2 px-4" (and opacity when disabled), px-3 lost on purpose

Wire CVA through the same helper so variant defaults and consumer className share one conflict policy:

tsx
className={cn(buttonVariants({ variant, size }), className)}

Order matters: put the escape hatch last so intentional overrides win.

When merge pays off

Use cn / twMerge when:

  • A primitive exposes className as a public escape hatch
  • Variants and consumers can specify the same Tailwind group
  • You compose multiple class sources (CVA + slot + caller)

Skip merge when:

  • The string is a closed set you fully control (no consumer classes)
  • You are not using Tailwind utility conflicts (CSS modules, plain CSS)
  • The "conflict" is actually two different concerns that should both apply (margin on the root + padding on an inner element, fix structure, don't merge)

Merging everything "for consistency" hides structure bugs. If two utilities should never meet on the same node, that is a component split, not a merge config.

Conflict resolution is a product decision

twMerge last-wins is not morally neutral. It encodes: the rightmost author is correct. In a design system that usually means:

  1. Base CVA classes
  2. Variant / compound classes
  3. Slot or recipe classes
  4. Consumer className
tsx
// Illustrative, consumer padding wins over size default <Button size="md" className="px-8" />

If product wants size to be sacred, do not expose padding via className for that component, expose size only, or document that overrides are unsupported. Merge cannot invent governance; it only enforces order.

Custom groups and Tailwind v4 notes

By mid-2025, Tailwind v4 is shipping in real apps. tailwind-merge needs a config that matches your theme tokens and custom utilities, especially if May's design-token post pushed semantic classes (bg-primary) that are not stock Tailwind.

ts
// illustrative, extend merge for project tokens import { extendTailwindMerge } from 'tailwind-merge'; export const twMerge = extendTailwindMerge({ extend: { classGroups: { 'bg-color': [{ bg: ['primary', 'danger', 'muted'] }], }, }, });

Wrong merge config looks like "merge is broken" in review. It is usually token classes treated as unrelated, so both survive and the cascade surprises you at runtime.

I do not claim a specific merge-bug rate without telemetry. The qualitative win is: conflicts become deterministic and reviewable.

A review checklist for class PRs

When someone "fixes spacing" by appending utilities, I ask:

  1. Did you override via merge order, or append a second conflicting class that only works by stylesheet luck?
  2. Should this be a CVA variant instead of a one-off className?
  3. Are we fighting tokens: adding bg-* because the semantic token is wrong or missing?
  4. Is the node doing two jobs (layout + chrome) that should be two elements?

If the same override appears three times across call sites, promote it. If it appears once on a marketing one-off, cn(recipe, className) is enough.

tsx
// Prefer promoting repeated overrides const toolbarButton = cva(buttonVariants({ variant: 'ghost', size: 'sm' }), { // or a dedicated size/variant, not copy-pasted className="px-1" });

Failure modes

clsx alone on public primitives. Two paddings ship; whoever ordered the template wins in the browser, not in types.

twMerge on non-Tailwind strings. Harmless most days, expensive noise when you pass CSS module hashes through it "just in case."

Fighting CVA with raw utilities. If every consumer overrides variant via className, the typed matrix is not doing real work. Fix the API or the culture.

Global cn that rewrites intent. Aggressive custom groups that merge unrelated utilities into one winner. Prefer small, documented extensions.

Merging third-party kit classes you do not own. Foreign contracts may use utility-shaped names; twMerge can strip the wrong one. Keep a raw clsx path for opaque external roots.

Continuity

CVA typed the variant matrix. Merge policy makes className composition safe enough to expose. Polymorphic components are the follow-on: when the element changes under those classes, and what typing and accessibility cost that flexibility.

Takeaway

clsx builds the list; tailwind-merge picks a winner. Composition without a conflict policy is just string order luck wearing a helper name.