tailwind-variants

tailwind-variants

The power of Tailwind combined with a first-class variant API.

npm downloads NPM Version License

## Features - First-class variant API - Slots support - Composition support (including multi-extend) - Fully typed - Framework agnostic - Built-in conflict resolution - Debugging and tracing (experimental) - Tailwind CSS v4 support ## Installation ```bash npm i tailwind-variants # or yarn add tailwind-variants # or pnpm add tailwind-variants ``` **Lite:** import from `tailwind-variants/lite` for a smaller bundle without conflict resolution. **Upgrading?** - v3 → v4: [migration guide](./.docs/migrations/v3-to-v4.md) - v2 → v3: [migration guide](./.docs/migrations/v2-to-v3.md) - v1 → v2: [migration guide](./.docs/migrations/v1-to-v2.md) ## Quick Start ```js import { tv } from "tailwind-variants"; const button = tv({ base: "font-medium bg-blue-500 text-white rounded-full active:opacity-80", variants: { color: { primary: "bg-blue-500 text-white", secondary: "bg-purple-500 text-white", }, size: { sm: "text-sm", md: "text-base", lg: "px-4 py-3 text-lg", }, }, compoundVariants: [ { size: ["sm", "md"], class: "px-3 py-1", }, ], defaultVariants: { size: "md", color: "primary", }, }); button({ size: "sm", color: "secondary" }); // => "font-medium rounded-full active:opacity-80 bg-purple-500 text-white text-sm px-3 py-1" ``` > **Note:** Tailwind CSS v4 no longer supports `config.content.transform`, so responsive variants > were removed. Add responsive classes to your class names manually if needed. ## Conflict Resolution Conflict resolution is built in. The default entry ships compiled merge tables and a single-pass engine. One engine serves `tv`, `cn`, and `twMerge`. No extra merge package is needed. ```js import { tv, cn, twMerge, twJoin, clsx } from "tailwind-variants"; cn("px-2", "px-4", { "py-1": true }); // => "px-4 py-1" twMerge("px-2", "px-4"); // => "px-4" tv({ base: "px-2", variants: { size: { lg: "px-4" } } })({ size: "lg" }); // => "px-4" ``` Repeated work stays off the render path. `cn` answers calls made with the same string instances from an argument cache. Recipes keep a pre-merged core per variant selection and cache `class` / `className` overrides by text, so a new string with the same text still hits. ### Performance Numbers come from `pnpm benchmark` (Node 24, median of three rounds). Use them for direction, not as absolute figures. - Against 3.3.1, `cn()` gains about 3× on join-and-merge and about 2× on unique arbitrary values. On stable arguments both sit in the same band: 3.3.1 already cached by argument identity. - Hot `tv()` calls run about 1.6×–2× faster than 3.3.1, and faster than cva on the same recipe. - Creating a component is slower than cva. `tv()` normalizes and pre-merges at definition so every call is cheaper. ### Aliasing `tailwind-merge` and `clsx` The package exports the same names as `tailwind-merge` (`twMerge`, `twJoin`) and `clsx` (`clsx`, also the default export). Alias both packages to it and run one engine. ```js // app that already uses tv resolve: { alias: { "tailwind-merge": "tailwind-variants", clsx: "tailwind-variants" } } // merge-only code, without the tv runtime resolve: { alias: { "tailwind-merge": "tailwind-variants/merge", clsx: "tailwind-variants/merge" } } ``` Code that calls `extendTailwindMerge` or `createTailwindMerge` aliases `tailwind-merge` to `tailwind-variants/config` instead. Known limits: - Unlabeled `font-[…]` follows Tailwind v4: `font-[Inter]` is a font family, `font-[700]` a weight. Labeled forms (`family-name:`, `weight:`) and CSS-variable forms are unchanged. - `cx`, `clsx`, and `twJoin` differ. `cx` normalizes whitespace, keeps a numeric `0`, and accepts objects. `twJoin` joins strings and arrays. `clsx` follows clsx. - `experimentalParseClassName` is not supported (see below). - `twMerge` takes strings and nested arrays. Object syntax belongs to `cn` and `clsx`. - Pick one engine per app. Alias everything here, or use the lite entry with the merger you already ship. - `TWMergeConfig` is the plain `{ extend, override, prefix, cacheSize }` shape. It is not generic over class-group ids. ### Custom configuration Custom utilities need the table compiler, which ships only on `tailwind-variants/config`. That entry exports the same API plus `twMergeConfig` on `tv`, `createTV`, and `cnMerge`, and the config API: `extendTailwindMerge`, `createTailwindMerge`, `createTwMerge`, `fromTheme`, `validators`, `mergeConfigs`, `getDefaultConfig`. `extend` appends to the defaults, `override` replaces them. ```ts import { createTV, extendTailwindMerge, tv, type TWMergeConfig } from "tailwind-variants/config"; const twMergeConfig = { extend: { classGroups: { elevation: ["elevation-low", "elevation-high"], }, }, } satisfies TWMergeConfig; tv({ base: "elevation-low", variants: { raised: { true: "elevation-high" } } }, { twMergeConfig }); createTV({ twMergeConfig }); extendTailwindMerge(twMergeConfig)("elevation-low elevation-high"); // => "elevation-high" ``` Each config compiles to its own tables once. Engines are cached by identity and by structure, so a config literal rebuilt on every call still compiles once. `prefix` and `cacheSize` are honored. `experimentalParseClassName` is not: inject a merge function that supports it through `twMerge`. The default entry throws on `twMergeConfig`, so a missed import fails at definition time. Disable merging with `{ twMerge: false }` on `tv`, `createTV`, or `cnMerge`. ### Lite build Import from `tailwind-variants/lite` when the app already has a merger, or needs none. Lite ships no engine and no tables. It joins, or calls the function you inject. ```ts import { createCN, createTV } from "tailwind-variants/lite"; import { twMerge } from "tailwind-variants/merge"; export const tv = createTV({ twMerge }); export const cn = createCN({ twMerge }); ``` Lite also exports a strings-only `clsx` (named and default), like `clsx/lite`. `createTV` does not bind `cn`. `debug` is default-entry only. `twMergeConfig` lives on `tailwind-variants/config`. ## Composition `extend` merges one or more parent recipes into a child. Pass a single `tv()` result, or a **non-empty** array merged left-to-right (child options win last). ```ts import { tv } from "tailwind-variants"; const focusable = tv({ base: "focus-visible:ring-2" }); const animated = tv({ base: "transition-all duration-150" }); const button = tv({ extend: [focusable, animated], base: "inline-flex items-center", }); // Single parent still works: const iconButton = tv({ extend: button, base: "gap-2" }); ``` Variants, slots, defaults, and compounds are deep-merged. If two recipes share a variant key that should stay separate, call them individually and join with `cx` / `cn` instead. ## Debugging (experimental) Enable `debug: true` to log how a recipe resolves classes and which ones the merger dropped. Output appears in the browser console only. ```ts import { tv } from "tailwind-variants"; const button = tv( { base: "inline-flex rounded bg-red-500 px-2", variants: { color: { primary: "bg-blue-600 text-white" }, size: { sm: "text-sm px-2", lg: "text-lg px-6" }, }, defaultVariants: { size: "sm" }, }, { debug: true }, ); button({ color: "primary", size: "lg" }); ``` In production builds the debug path is fully tree-shaken via `process.env.NODE_ENV` — no logging code, no overhead, and recipe behavior remains identical. The `/lite` entry does not support debug. ## Utility Functions | Function | Behavior | | --------- | ---------------------------------------------------- | | `cx` | Concatenate class names (no merging) | | `cn` | Concatenate and merge with the default config | | `cnMerge` | Concatenate and merge, with optional per-call config | On `/lite`, `cn` only joins. Use `createCN({ twMerge })` to bind a merger. ```js import { cx, cn, cnMerge } from "tailwind-variants"; cx("px-2", "px-4"); // => "px-2 px-4" cn("px-2", "px-4"); // => "px-4" cnMerge("px-2", "px-4")({ twMerge: false }); // => "px-2 px-4" ``` `cn` follows `clsx` for falsy values: `cn("foo", 0)` is `"foo"`, while a lone `cn(0)` is `"0"`. `cx` keeps `0` in every position. See the [v3 to v4 migration guide](.docs/migrations/v3-to-v4.md). These utilities — and `tv()` results, including slot functions — always return a `string`; when no classes remain, the result is an empty string, matching common class name utilities. ## Documentation For full documentation, visit [tailwind-variants.org](https://tailwind-variants.org). ## Acknowledgements - [**cva**](https://github.com/joe-bell/cva) ([Joe Bell](https://github.com/joe-bell)) This project started as an extension of Joe's work on `cva` — a great tool for generating variants for a single element with Tailwind CSS. Big shoutout to [Joe Bell](https://github.com/joe-bell) and [contributors](https://github.com/joe-bell/cva/graphs/contributors)! If you don't need the **Tailwind Variants** features listed [here](https://www.tailwind-variants.org/docs/comparison), we recommend `cva`. - [**Stitches**](https://stitches.dev/) ([Modulz](https://modulz.app)) The pioneers of the `variants` API movement. Immense thanks to [Modulz](https://modulz.app) for their work on Stitches and the community around it. - [**shadcn-ui/cn**](https://github.com/shadcn-ui/cn) ([shadcn](https://github.com/shadcn)), [**tailwind-merge**](https://github.com/dcastil/tailwind-merge) ([Dany Castillo](https://github.com/dcastil)), [**cnfast**](https://github.com/aidenybai/cnfast) ([Aiden Bai](https://github.com/aidenybai)), and [**clsx**](https://github.com/lukeed/clsx) ([Luke Edwards](https://github.com/lukeed)) The merge engine is a snapshot of `cn`, with `tailwind-merge`'s conflict rules, `clsx`'s argument semantics, and an argument cache in the spirit of `cnfast`. All four are MIT licensed. See [LICENSE](./LICENSE) and the linked repositories. ## Community We're excited to see the community adopt HeroUI, raise issues, and provide feedback. Whether it's a feature request, bug report, or a project to showcase, please get involved! - [Discord](https://discord.gg/9b6yyZKmH4) - [Twitter](https://twitter.com/getnextui) - [GitHub Discussions](https://github.com/heroui-inc/tailwind-variants/discussions) ## Contributing Contributions are always welcome! - [Contributing guidelines](./CONTRIBUTING.md) - [Code of conduct](./CODE_OF_CONDUCT.md) - [Security policy](./SECURITY.md) ## Authors - Junior Garcia ([@jrgarciadev](https://github.com/jrgarciadev)) - Tianen Pang ([@tianenpang](https://github.com/tianenpang)) ## License Licensed under the [MIT License](./LICENSE).