tailwind-merge : Deterministic Class Override
tailwind-merge is a runtime library that takes any number of
Tailwind class strings and returns ONE string where conflicting
utilities are resolved with a LAST-WINS rule.
It is the foundation of the shadcn/ui cn() helper, the cva-based
component variant pattern, and any React/Vue/Svelte component that
accepts a className prop and combines it with internal defaults.
Companion skills :
tailwind-impl-config-v3andtailwind-impl-config-v4: the Tailwind side of the equation.tailwind-impl-plugins-official: how the official plugins emit class groups that tailwind-merge needs to know about.- shadcn-ui skill package : the upstream design system that
popularised the
cnhelper.
Quick Reference : The Problem
// Component default
function Card({ className }) {
return <div className={`p-2 bg-white ${className}`} />;
}
// Caller passes override
<Card className="p-4" />
Output markup contains p-2 bg-white p-4. The compiled CSS contains
BOTH .p-2 and .p-4. Whichever appears later in the stylesheet
wins. With JIT this is non-deterministic across builds.
Quick Reference : The Fix
import { twMerge } from "tailwind-merge";
function Card({ className }) {
return <div className={twMerge("p-2 bg-white", className)} />;
}
<Card className="p-4" />
Output markup contains bg-white p-4. Deterministic. p-2 is removed
because it conflicts with p-4 in the same padding class group.
Quick Reference : Install
| Tailwind version | tailwind-merge version | npm install |
|---|---|---|
| v3.x | 2.x (latest 2.6.0) | npm install tailwind-merge@2 |
| v4.x | 3.x (current) | npm install tailwind-merge@3 |
ALWAYS pin to the major matching your Tailwind version. v2.x knows about v3-era class groups ; v3.x knows about v4-era class groups. A mismatch silently produces wrong merges for utilities the library does not recognise.
Quick Reference : The shadcn cn Helper
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Used everywhere :
<button className={cn("px-3 py-1 rounded", isPrimary && "bg-blue-600", className)}>
clsx resolves conditionals + arrays + objects into a single string ;
twMerge then resolves Tailwind-level conflicts.
Decision Tree : Do I Need tailwind-merge
Does the class list ever come from props or user input ?
├── NO (always static, hard-coded in JSX) -> NO twMerge needed
│ Example: <div className="p-4 bg-blue-500"> -> just write it
│
└── YES (className prop, variant logic, theme switching, etc.)
-> YES, use twMerge OR cn(clsx(...))
Are the classes conditional but never conflict ?
├── YES (e.g. show/hide a class only) -> twJoin or clsx are enough
│ Example: clsx("base", isActive && "bg-blue-500")
│
└── YES (different sizes/variants override each other)
-> twMerge IS required
Decision Tree : twMerge vs twJoin vs clsx vs cn
| Tool | Conflict resolution | Conditionals | Bundle |
|---|---|---|---|
twMerge |
yes (LAST WINS) | only strings | larger (full data structure) |
twJoin |
no | strings + arrays + nullables | smaller |
clsx |
no | strings + arrays + objects + nullables | smallest |
cn (shadcn) |
yes | everything clsx accepts | largest |
ALWAYS pick the lightest tool that does the job :
Static classes only -> just write the string
Conditional, no conflict possible -> clsx OR twJoin
Conditional, conflicts possible -> cn(clsx(...))
External override via className prop -> twMerge OR cn
API : Primary Functions
twMerge(...classLists)
function twMerge(
...classLists: Array<string | undefined | null | false | 0 | ClassLists>
): string;
Accepts variadic strings and nullables. Returns one merged string. LAST conflicting class wins. Falsy values are ignored. Nested arrays are flattened.
twJoin(...classLists)
function twJoin(
...classLists: Array<string | undefined | null | false | 0 | ClassLists>
): string;
Same signature, NO conflict resolution. Just concatenation with
whitespace and falsy filtering. Faster + smaller than twMerge. Use
when you know nothing will conflict.
extendTailwindMerge(configExtension, ...createConfig)
Returns a CUSTOM twMerge function that knows about additional class
groups, theme scales, prefix, or conflict rules. ALWAYS call this
ONCE at module scope, NEVER inside a render path.
import { extendTailwindMerge } from "tailwind-merge";
export const twMerge = extendTailwindMerge({
prefix: "tw-",
extend: {
classGroups: {
"btn-size": ["btn-sm", "btn-md", "btn-lg"],
},
},
});
createTailwindMerge(...createConfig)
Returns a twMerge that uses ONLY your config (no inheritance from
defaults). Used when you have NO Tailwind core classes at all and a
fully bespoke utility set. Rare.
getDefaultConfig()
Returns the default config object. Use as a starting point for
mergeConfigs or to inspect what tailwind-merge knows about.
mergeConfigs(...configs)
Combines multiple configs. Useful when composing extensions from several design-system packages.
fromTheme(key)
Returns a "theme getter" function used inside classGroups to
reference theme scales. Lets extendTailwindMerge resolve dynamic
values from the Tailwind theme.
validators
A module of helper validators for class-group patterns. Examples :
isNumber, isLength, isArbitraryValue, isArbitraryLength,
isPercent, isTshirtSize. Pass into classGroups to accept any
matching arbitrary value.
CRITICAL : Performance Rule
extendTailwindMerge builds a large internal data structure. Calling
it on every render costs milliseconds per call AND defeats the
internal LRU cache.
Wrong (defeats memoisation)
function Button({ className }) {
const twMerge = extendTailwindMerge({ prefix: "tw-" }); // rebuilt every render
return <button className={twMerge("px-3 py-1", className)} />;
}
Right
// src/lib/tw-merge.ts (module scope)
import { extendTailwindMerge } from "tailwind-merge";
export const twMerge = extendTailwindMerge({ prefix: "tw-" });
// Component
import { twMerge } from "@/lib/tw-merge";
function Button({ className }) {
return <button className={twMerge("px-3 py-1", className)} />;
}
The default cache is 500 entries (LRU). Tune via cacheSize if
profiling shows lots of cache misses on a hot path.
CRITICAL : Prefix Configuration
If your Tailwind config has a prefix (e.g. tw-), tailwind-merge
MUST be told :
// v3 Tailwind config has prefix: 'tw-'
export const twMerge = extendTailwindMerge({ prefix: "tw-" });
Without this, tw-p-2 tw-p-4 is NOT recognised as a padding conflict
and both classes survive.
class-variance-authority (cva) Pairing
cva defines variant-based component class strings as data. Combine
with twMerge so consumer overrides still win.
Install
npm install class-variance-authority clsx tailwind-merge
Define a button variant set
// src/components/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
{
variants: {
variant: {
default: "bg-blue-600 text-white hover:bg-blue-700",
outline: "border border-zinc-300 text-zinc-900 hover:bg-zinc-100",
ghost: "text-zinc-900 hover:bg-zinc-100",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: { variant: "default", size: "md" },
}
);
export type ButtonProps = VariantProps<typeof buttonVariants> & {
className?: string;
children: React.ReactNode;
};
export function Button({ className, variant, size, children, ...rest }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant, size }), className)} {...rest}>
{children}
</button>
);
}
Consumer override
<Button variant="outline" size="lg" className="rounded-full px-10">
Click me
</Button>
rounded-md from the base is replaced by rounded-full because they
conflict in the border-radius group. px-6 from size: lg is
replaced by px-10. Other classes survive.
CRITICAL : When NOT to Use tailwind-merge
Static utility list with no possible conflicts ?
-> Don't use it. Just write the string.
A single condition that adds or removes one class ?
-> Use clsx or twJoin. Lighter.
Server-rendered output with NO client interaction ?
-> Consider precomputing at build time, not at request time.
Email template renderer (HTML to many clients) ?
-> tailwind-merge runs in the renderer fine. Tailwind itself does
not (no JS in email clients).
Every twMerge call has a runtime cost (cache lookup or full parse).
Avoid the hot-path tax when not needed.
Cross-Link : shadcn/ui Pattern
The shadcn/ui design system standardises ALL components on the
cn helper that wraps twMerge and clsx. If you adopt shadcn :
src/lib/utils.tsexposescn.- Every component uses
cnfor its root className. - cva is used for variant-based components (Button, Badge, Alert).
- See the companion shadcn-ui Claude Skill Package for the full component set and the install scripts.
What This Skill Does NOT Cover
- Writing your own Tailwind utility classes (use
tailwind-impl-config-v3ortailwind-impl-config-v4). - The Tailwind cascade itself (use
tailwind-syntax-cascade). - Tailwind plugin authorship (use
tailwind-impl-plugins-custom). - shadcn/ui component recipes (use the shadcn-ui skill package).
References
references/methods.md: full API surface, all functions, all validators, all options.references/examples.md: shadcn cn helper, cva button, custom prefix, design-system variant patterns.references/anti-patterns.md: in-render extendTailwindMerge, version mismatch, prefix-mismatch, twMerge on static classes, unnecessary cn wrapping.
Official Source Links
- Repo : https://github.com/dcastil/tailwind-merge
- API reference v3 : https://github.com/dcastil/tailwind-merge/blob/v3/docs/api-reference.md
- Configuration v3 : https://github.com/dcastil/tailwind-merge/blob/v3/docs/configuration.md
- class-variance-authority : https://github.com/joe-bell/cva
- cva docs : https://cva.style