npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cn-variants

v4.0.0

Published

Tiny utilities for working with Tailwind CSS class names. Combines clsx + tailwind-merge with a typed variants helper.

Readme

cn-variants

npm version CI

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-variants

cn-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 className last in cn() 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@^4

v4 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-merge

For 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