cn-variants
v4.0.0
Published
Tiny utilities for working with Tailwind CSS class names. Combines clsx + tailwind-merge with a typed variants helper.
Maintainers
Readme
cn-variants
Documentation · Button playground · Badge playground · Design decisions
Tiny utilities for working with Tailwind CSS class names. Combines clsx + tailwind-merge with a typed variants helper.
Install
npm install cn-variantscn-variants is ESM-only. Use import or dynamic import(); CommonJS require() is not supported.
API at a glance
import { cn, type Variant, variants } from "cn-variants";
const buttonTone = variants({
primary: "bg-indigo-600 text-white",
danger: "bg-red-600 text-white",
});
const buttonSize = variants({
sm: "px-3 py-1 text-xs",
md: "px-4 py-2 text-sm",
});
type ButtonTone = Variant<typeof buttonTone>;
type ButtonSize = Variant<typeof buttonSize>;
interface ButtonClassOptions {
tone?: ButtonTone;
size?: ButtonSize;
className?: string;
}
function buttonClasses({ tone = "primary", size = "md", className }: ButtonClassOptions = {}) {
return cn("rounded-md font-medium", buttonTone(tone), buttonSize(size), className);
}The API deliberately differs from CVA-style configuration objects:
- Pass a direct key-to-class map to
variants({ primary: "..." }). - Use one
variants()call for each axis, such as tone or size. - Call the result with one key, such as
buttonTone("primary"); it does not accept an options object. - Put defaults and compound conditions in the component that composes the class strings.
- Put a caller-provided
classNamelast incn()when it should win Tailwind conflicts. - Import through the ESM package entry point; internal modules are not public exports.
When to use this
Use cn-variants when you want the familiar cn merge plus a typed lookup for one axis at a time. If you already have shadcn's cn, you can keep that merger and still use variants. Reach for CVA (cva()) or tailwind-variants (tv()) when you want a multi-axis configuration object, default variants, compoundVariants, or slots. cn-variants does not add those features.
cn(...inputs)
Merges class names using clsx and tailwind-merge. Handles conditionals, duplicates, and Tailwind conflicts.
import { cn } from "cn-variants";
// Tailwind conflict resolution — last value wins
cn("px-4 py-2", "px-6");
// → "py-2 px-6"
// Conditionals
cn("text-red-500", isActive && "text-blue-500");
// → "text-blue-500" (when isActive is true)
// Object syntax
cn("flex", { "gap-4": hasGap, "items-center": centered });
// → "flex gap-4 items-center" (when both are true)
// Arrays
cn(["rounded-lg", "shadow-md"], "p-4");
// → "rounded-lg shadow-md p-4"
// Mixed — all clsx input types work
cn("base", ["flex", "gap-2"], { "font-bold": isActive }, undefined, null, false);
// → "base flex gap-2 font-bold" (when isActive is true)variants(map)
Creates a typed lookup function for Tailwind class variants. The map is captured with const generics, so object keys stay as string literals for autocomplete and compile-time safety. Values can be a class string or an array of class strings, which are joined with spaces. Returns "" for unknown keys at runtime, relying on TypeScript for compile-time safety. undefined is also accepted and returns "", so optional props can be passed straight through:
import { cn, variants } from "cn-variants";
const buttonTone = variants({
primary: "bg-indigo-600 text-white border-none",
secondary: "bg-transparent text-indigo-600 border border-indigo-600",
danger: "bg-red-600 text-white border-none",
});
const buttonSize = variants({
sm: "px-3 py-1 text-xs",
md: ["px-4", "py-2", "text-sm"],
lg: "px-6 py-3 text-base",
});The returned function accepts only the keys from the map:
buttonTone("primary");
// ✅ ok
buttonTone("ghost");
// ❌ TypeScript error: expected "primary" | "secondary" | "danger"
buttonTone(undefined);
// ✅ ok — returns ""Inferring variant types
Use the Variant helper type when you need the union for component props:
import { type Variant, variants } from "cn-variants";
const buttonTone = variants({
primary: "bg-indigo-600 text-white border-none",
secondary: "bg-transparent text-indigo-600 border border-indigo-600",
danger: "bg-red-600 text-white border-none",
});
type ButtonTone = Variant<typeof buttonTone>;
// → "primary" | "secondary" | "danger"The returned function also exposes a frozen .options snapshot, so keyof typeof buttonTone.options still works if you prefer that style. Creating a variant does not freeze or retain the object passed by the caller. The snapshot includes copied frozen arrays, so later mutation of the source does not change lookup or .options. Its TypeScript type also makes those arrays readonly, even when the input contains mutable arrays.
Empty-string keys are allowed and behave like any other key — they are looked up with the same rules, so variants({ "": "hidden" })("") returns "hidden".
Annotating wrappers with VariantsOf
Use the VariantsOf helper type when a function parameter should accept any lookup created by variants():
import { type VariantsOf } from "cn-variants";
function applyStyle(lookup: VariantsOf<typeof buttonSize>, size?: ButtonSize) {
return cn("font-medium", lookup(size));
}The VariantValue type (string | readonly string[]) is exported too, in case you type variant maps directly.
Using variants with cn in components
type ButtonSize = Variant<typeof buttonSize>;
interface ButtonProps {
tone?: ButtonTone;
size?: ButtonSize;
className?: string;
children: React.ReactNode;
}
export function Button({ tone = "primary", size = "md", className, children }: ButtonProps) {
return (
<button className={cn("rounded-md font-medium", buttonTone(tone), buttonSize(size), className)}>
{children}
</button>
);
}Callers can override any style through className — tailwind-merge ensures the caller's classes win:
<Button tone="primary" className="bg-purple-600">
{/* bg-purple-600 overrides the primary variant's bg-indigo-600 */}
</Button>Compound variants
For styles that depend on a combination of variants, use conditionals in cn:
cn(
"rounded-md font-medium",
buttonTone(tone),
buttonSize(size),
tone === "primary" && size === "lg" && "uppercase tracking-wide",
tone === "danger" && "ring-2 ring-red-300",
className,
);ClassValue type
If you write wrapper functions around cn, you can import the ClassValue type directly:
import { type ClassValue, cn } from "cn-variants";
function card(...classes: ClassValue[]) {
return cn("rounded-lg border bg-white shadow-sm", ...classes);
}Tailwind IntelliSense
To get autocomplete for class strings inside variants() and cn(), add them to the classFunctions setting in your editor's Tailwind CSS configuration.
VS Code
Install the Tailwind CSS IntelliSense extension, then add to your .vscode/settings.json:
{
"tailwindCSS.classFunctions": ["cn", "variants"]
}Zed
Add to your project's .zed/settings.json:
{
"lsp": {
"tailwindcss-language-server": {
"settings": {
"classFunctions": ["cn", "variants"]
}
}
}
}IntelliJ IDEA / WebStorm
Install the Tailwind CSS plugin, then open Settings | Languages & Frameworks | Style Sheets | Tailwind CSS. Add the following language-server option in the Configuration area:
{
"classFunctions": ["cn", "variants"]
}classFunctions configures the editor's language server; it is not a tailwind.config.js property.
Philosophy: no mini DSL
cn-variants intentionally keeps compound styles as plain JavaScript expressions instead of adding a compoundVariants configuration API. If you can write it as a JavaScript expression, cn-variants should not invent an API for it.
cn(
base(),
intent(kind),
sizeVariant(size),
loading && "opacity-50",
iconOnly && "aspect-square",
kind === "primary" && size === "lg" && "uppercase",
);That keeps matching rules, precedence, and debugging in userland where TypeScript and your editor already understand them.
Bring your own cn
variants returns plain strings, so it composes with any class-merging function, not just the bundled cn. If your app re-renders heavily and class merging shows up in profiles, you can pair variants with a faster merger like cnfast without changing anything else:
import { cn } from "cnfast";
import { variants } from "cn-variants";
const buttonTone = variants({
primary: "bg-indigo-600 text-white",
secondary: "bg-transparent text-indigo-600",
});
cn("rounded-md font-medium", buttonTone("primary"));Custom Tailwind configurations
cn uses tailwind-merge with its default configuration. If your project uses a custom Tailwind theme — for example a custom font size named text-caption — you may want conflict resolution that understands those utilities. createCn is the built-in escape hatch for this.
import { createCn } from "cn-variants";
import { extendTailwindMerge } from "tailwind-merge";
const cn = createCn(
extendTailwindMerge({
extend: {
classGroups: {
"font-size": [{ text: ["caption", "body"] }],
},
},
}),
);
cn("text-caption", "text-red-500");
// → "text-caption text-red-500" (custom font size and color coexist)variants returns plain strings, so it composes with any merge function unchanged. Wrapping the merger in createCn also gives you full clsx input support (cn("base", { bold: true })), just like the built-in cn.
The named createCn export tree-shakes when unused. cn is implemented with that factory internally, so importing cn retains the factory code. Importing only variants still does not pull clsx or tailwind-merge into a bundler. The MergeClasses type ((classes: string) => string) is exported too, so you can annotate mergers and wrapper functions directly:
import { type MergeClasses } from "cn-variants";
const merge: MergeClasses = (classes) => myCustomMerger(classes);React Server Components
cn and variants are pure, synchronous functions with no module state, side effects, or platform APIs. They work unchanged in React Server Components and any other server environment — no "use client" boundary is needed for the library itself.
Tree-shaking
cn and variants are independent modules. If you only import variants, your bundler will tree-shake away cn, createCn, and their dependencies (clsx, tailwind-merge), keeping your bundle minimal. The createCn named export also tree-shakes when unused; importing cn retains the factory because cn uses it internally.
The package declares "sideEffects": false so bundlers can apply this optimization safely.
CI bundles each runtime export from the packed package and checks which peer dependencies remain. Separate size budgets cover minified consumer bundles, including retained peers.
Versioning policy
cn-variants follows semver and declares clsx and tailwind-merge as peer dependencies, with these supported ranges: clsx ^2.1.1 and tailwind-merge ^3.5.0. You install and version them alongside cn-variants, so your bundler sees exactly one copy.
- Patch/minor upstream releases are absorbed automatically. No action needed on your part.
- Major upstream releases may change observable behavior (e.g. how tailwind-merge resolves conflicting utilities). When this happens, cn-variants will release a new major version that bumps the dependency range.
If cn("px-2", "px-4") returns a different result because of an upstream update, that's a breaking change from your perspective and will be treated as one.
Migrating to v4
npm install cn-variants@^4v4 gives array snapshots readonly types that match their frozen runtime behavior. The copying and freezing of string-keyed arrays was introduced in 3.0.1; v4 also handles declared symbol keys. In 3.0.0, source arrays were shared with the lookup. If you changed them later, lookup results changed too. The original definition now stays fixed:
import { variants } from "cn-variants";
const classes = ["px-2"];
const size = variants({ sm: classes });
classes.push("py-2");
size("sm"); // v3.0.0: "px-2 py-2"; v4: "px-2"
// Create a new lookup when definitions change.
const updatedSize = variants({ sm: [...size.options.sm, "py-2"] });
updatedSize("sm"); // "px-2 py-2".options arrays and tuples are readonly in TypeScript, including when the input is mutable. Calls such as size.options.sm.push("py-2") are type errors and throw at runtime if types are bypassed. The caller's own arrays remain mutable.
Existing string maps and immutable array usage need no changes. The runtime exports, ESM-only entry point, and peer dependency ranges remain unchanged: clsx ^2.1.1 and tailwind-merge ^3.5.0.
Migrating to v3
v3 declares clsx and tailwind-merge as peer dependencies. npm 7+ and other modern package managers install peers automatically; if yours doesn't, run:
npm install clsx tailwind-mergeFor v4, your versions must satisfy clsx ^2.1.1 and tailwind-merge ^3.5.0. When upgrading from v2, lookup functions also accept undefined, and unknown keys are rejected by TypeScript. See the CHANGELOG for details.
License
MIT
