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)); }
tscn('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:
tsxclassName={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
classNameas 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:
- Base CVA classes
- Variant / compound classes
- Slot or recipe classes
- 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:
- Did you override via merge order, or append a second conflicting class that only works by stylesheet luck?
- Should this be a CVA variant instead of a one-off
className? - Are we fighting tokens: adding
bg-*because the semantic token is wrong or missing? - 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.