The power of Tailwind combined with a first-class variant API.
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
npm i tailwind-variants
yarn add tailwind-variants
pnpm add tailwind-variants
Lite: import from tailwind-variants/lite for a smaller bundle without conflict resolution.
Upgrading?
Quick Start
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" });
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.
import { tv, cn, twMerge, twJoin, clsx } from "tailwind-variants";
cn("px-2", "px-4", { "py-1": true });
twMerge("px-2", "px-4");
tv({ base: "px-2", variants: { size: { lg: "px-4" } } })({ size: "lg" });
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.
resolve: { alias: { "tailwind-merge": "tailwind-variants", clsx: "tailwind-variants" } }
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.
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");
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.
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).
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",
});
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.
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
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.
import { cx, cn, cnMerge } from "tailwind-variants";
cx("px-2", "px-4");
cn("px-2", "px-4");
cnMerge("px-2", "px-4")({ twMerge: false });
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.
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.
Acknowledgements
-
cva (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 and
contributors! If you don't need the
Tailwind Variants features listed
here, we recommend cva.
-
Stitches (Modulz)
The pioneers of the variants API movement. Immense thanks to Modulz for
their work on Stitches and the community around it.
-
shadcn-ui/cn (shadcn),
tailwind-merge (Dany Castillo),
cnfast (Aiden Bai), and
clsx (Luke Edwards)
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 and the linked repositories.
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!
Contributing
Contributions are always welcome!
Authors
License
Licensed under the MIT License.