Open-source project
heroui-inc/tailwind-variants avatar
heroui-inc/tailwind-variants

tailwind-variants: a typed variant API for Tailwind CSS

🦄 Tailwindcss first-class variant API

3,310 stars89 forksTypeScriptMIT

At a glance

What is it?
tailwind-variants turns Tailwind utility strings into typed variant functions with slots, compound rules and built-in conflict resolution. It suits component libraries that style several elements from one definition, and it is the wrong tool when a single-element helper is enough.
Who is it for?
Adopt tailwind-variants if you are building a component library where one style definition has to drive several elements at once, or where you want Tailwind conflict resolution without wiring tailwind-merge yourself. Do not adopt it for a single button component: the README itself points that case at cva.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 20 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 29, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem tailwind-variants solves for component libraries

Tailwind gives you utility classes, not a component API. The moment a design system needs a button with three sizes, four colors and a disabled state, the class string stops being readable and starts being assembled with template literals and ternaries. tailwind-variants addresses that by letting you declare the variant space once and call a function to get the resulting class string back.

The README frames it as "The power of Tailwind combined with a first-class variant API." The package is published as tailwind-variants on npm, written in TypeScript, MIT licensed, and the repository lists the topics classnames, css, tailwindcss and variants. It is framework agnostic, so the same definition works in React, Vue, Svelte or plain DOM code, because what comes out is a string.

The audience is narrower than "anyone using Tailwind". If you have one element and two states, a plain object of class strings is enough. tailwind-variants earns its place when the style definition is shared, when several elements are styled by one definition, or when variants interact and you would otherwise write nested conditionals. The README's feature list names exactly those cases: first-class variant API, slots support, composition support, fully typed, framework agnostic, built-in conflict resolution and Tailwind CSS v4 support.

How tv() resolves variants, slots and compound rules

The core export is tv. You pass a configuration object with base, variants, compoundVariants and defaultVariants, and you get back a function. Calling that function with a set of variant values returns the merged class string.

The README's example is a button with color and size variants. The base holds shared classes, each variant key maps option names to classes, defaultVariants supplies fallbacks, and compoundVariants applies extra classes when a combination matches. In the example, size sm and md both receive px-3 py-1 through a compound rule written as size: ["sm", "md"].

Two details in that example are worth reading carefully. The base declares bg-blue-500 and text-white, and the primary variant declares the same two classes. The documented output keeps only one of each, which is the conflict resolution layer working during composition rather than after it. The output also reorders classes: the base classes survive, the variant classes are appended, and the compound classes come last.

Slots are the other mechanism, and they are the reason this package exists separately from cva. A slotted definition returns one class string per named region, so a card with header, body and footer can be described in one place instead of calling a single-element helper three times. The README lists slots and composition as features but does not show a full slotted example, so read the site documentation at tailwind-variants.org before designing a component around them. That gap is worth noting: slots are the headline differentiator, and the README leaves the detail to the website.

Conflict resolution, lite mode and the twMergeConfig escape hatch

Conflict resolution ships in the default entry point. The README states it is available on tv, createTV, cn and cnMerge, and that cx and the /lite entry do not merge. That distinction matters for bundle size: importing from tailwind-variants/lite gives you a smaller build without the merge layer, at the cost of having to order your classes correctly yourself.

The utility functions differ in a way that is easy to get wrong. cx concatenates and does not merge. cn concatenates and merges with the default config. cnMerge concatenates and merges with an optional per-call config. The README shows cx("px-2", "px-4") returning both classes, while cn("px-2", "px-4") returns only px-4.

For custom utilities the package accepts a twMergeConfig. The README recommends the { extend, override } shape, where extend appends to the defaults and override replaces them, and it shows a custom elevation class group registered that way. If you already configure tailwind-merge, you can reuse the same object, but the README is explicit that you pass the config object and not the merge function returned by extendTailwindMerge, and that merge functions and full default configs cannot be passed directly. That constraint is the most likely source of a confusing failure when migrating an existing tailwind-merge setup, because the mistake is silent: you pass the wrong shape and nothing merges as you expect.

Merging can also be turned off per call. The README documents { twMerge: false } on tv, createTV and cnMerge, which is the escape hatch when the merge layer is guessing wrong about a custom utility you have not registered.

Installing tailwind-variants and writing a first definition

The README gives three equivalent install commands. Pick the one that matches your package manager.

bash
npm i tailwind-variants
# or
yarn add tailwind-variants
# or
pnpm add tailwind-variants

Then define a variant function. This is the README's quick start, trimmed to the parts that demonstrate the API.

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" },
});

Calling button({ size: "sm", color: "secondary" }) returns "font-medium rounded-full active:opacity-80 bg-purple-500 text-white text-sm px-3 py-1". Compare that against the base string and you can see the duplicate background and text colors collapsed to a single pair, and the compound padding appended at the end. That output string is the whole contract: you hand it to className and nothing else in your component needs to know about variants.

If you only need concatenation, the utility functions are separate imports.

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"

The package exposes three entry points through its exports map: the root, ./lite and ./utils, each with both ESM and CommonJS builds and separate type declarations. If your bundler resolves the exports field, you get the right build without configuration.

Where tailwind-variants is the wrong choice

The README's own comparison section recommends cva for projects that do not need the features listed there. That is an unusually direct statement from a maintainer, and it should be taken at face value. If your components are single elements with a handful of variants, cva is smaller and has less surface area.

The more concrete limitation is responsive variants. The README states that Tailwind CSS v4 no longer supports config.content.transform, and that responsive variants were removed as a result. The note tells you to add responsive classes to your class names manually. If your design system encodes breakpoint behaviour inside variant definitions, v3 removes that capability, and the migration guide at .docs/migrations/v2-to-v3.md is where the change is documented. This is not a bug that will be patched; it follows from a Tailwind change.

There is also a runtime cost that the README does not quantify. Every call to a tv function runs class merging, and the /lite entry exists precisely because that work is not free. The repository contains a benchmark directory and a benchmark script in package.json, so measurements exist in the project, but the README publishes no numbers. Treat the lite entry as the release valve rather than expecting a documented performance figure.

Finally, the merge layer only knows the utilities it has been taught. Custom class groups need to be registered through twMergeConfig, and until they are, conflicting custom utilities will both survive into the output. A design system built on plugin-defined utilities will hit this on day one.

tailwind-variants vs cva and tailwind-merge

Against cva, the difference is scope, not quality. cva generates variants for a single element. tailwind-variants adds slots, composition and built-in conflict resolution. The README credits cva as the project's starting point and links to a comparison page on the documentation site for the full feature list. If you have ever styled a multi-part component by calling a cva function once per element, slots are the feature that removes that duplication.

Against tailwind-merge, the difference is where the merge happens. tailwind-merge is a merge function you call yourself, typically wrapped with clsx into a cn helper. tailwind-variants calls the merge layer for you at the end of variant resolution, so the class string you get back has already been deduplicated. It also accepts a tailwind-merge config as twMergeConfig, which means the two are not mutually exclusive: you can keep an existing extendTailwindMerge configuration and hand the same object to createTV.

The practical decision rule is the one the README implies. If you want merging and nothing else, use tailwind-merge or cn. If you want variants on one element, use cva. If you want variants, slots and merging from one definition, that is the case tailwind-variants was built for. The three tools overlap, but only one of them resolves variants and merges the result in a single call.

Maintenance, licensing and upgrade cost

The repository is not archived. The most recent push recorded for the default branch is 2026-09-10, and the latest release is v3.3.1 from 2026-08-03, following v3.3.0 on 2026-07-26 and v3.2.2 on 2025-11-22. The gap between v3.2.2 and v3.3.0 is roughly eight months, which is a normal cadence for a library of this size but worth knowing if you depend on prompt fixes.

The project is MIT licensed, and the package.json declares "license": "MIT". That is permissive and places few obligations on how you redistribute the code, but it is a licence fact, not legal advice. The README also acknowledges that conflict resolution draws on MIT-licensed work from tailwind-merge, clsx and cnfast.

Upgrade cost is dominated by the v2 to v3 migration, because of the responsive variant removal tied to Tailwind CSS v4. The repository keeps versioned migration guides under .docs/migrations/ for both v2 to v3 and v1 to v2, so the path is documented. If you are still on Tailwind v3 and rely on responsive variants, moving to tailwind-variants v3 means auditing every variant definition that encodes a breakpoint. That audit, not the dependency bump, is the real work.

Editorial conclusion

Adopt tailwind-variants if you are building a component library where one style definition has to drive several elements at once, or where you want Tailwind conflict resolution without wiring tailwind-merge yourself. Do not adopt it for a single button component: the README itself points that case at cva. Before committing, check one thing in your own codebase: whether your Tailwind version still gives you responsive variants, because the README states that Tailwind CSS v4 removed config.content.transform and that responsive variants were removed with it. If you rely on them, they have to move into your class names by hand, and that is the migration cost you should price before upgrading.

Frequently asked questions

How do I use tailwind-variants?

Install it with npm i tailwind-variants, import tv, and pass a configuration object containing base, variants, compoundVariants and defaultVariants. The call returns a function; calling that function with variant values returns the resolved class string.

What is tailwind-variants?

It is a TypeScript library that gives Tailwind CSS a variant API, described in the README as a first-class variant API. It supports slots, composition, full typing and built-in conflict resolution, and it is framework agnostic.

What is the difference between tailwind-variants and tailwind-merge?

tailwind-merge is a merge function you call yourself, while tailwind-variants runs conflict resolution as part of resolving a variant definition. The README states that merging is built into the default entry and is available on tv, createTV, cn and cnMerge, and that a tailwind-merge config can be reused as twMergeConfig.

Is tailwind-variants an alternative to cva?

The README describes the project as having started as an extension of cva and recommends cva itself for anyone who does not need the additional features. The added features are slots, composition and built-in conflict resolution.

Does tailwind-variants work with Tailwind CSS v4?

Yes. Tailwind CSS v4 support is listed among the README's features, but the same README notes that v4 no longer supports config.content.transform, so responsive variants were removed and responsive classes must be added manually.

Official sources

  1. heroui-inc/tailwind-variants on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/heroui-inc-tailwind-variants.svg)](https://hysenlabs.com/projects/heroui-inc-tailwind-variants)