How to Declaratively Build a Variant System for Button & Card Components with tailwind-variants — Using Slots, extend, and compoundVariants
This article is written for frontend developers who are already using Tailwind CSS and find managing component variants cumbersome.
There's a problem you'll inevitably run into when working with Tailwind. You build a button, and once primary color, lg size, and disabled state all need to overlap, your if statements and ternary operators start tangling together. At first I thought managing conditional classes with clsx would be enough — but as the number of components grew, there came a moment of "is this really the right way?" If you've worked with CSS-in-JS, you might remember how Stitches' variant concept neatly resolved that frustration. tailwind-variants is exactly the library that brings that philosophy into the Tailwind world.
In this article, we'll walk through building a style management system for Button, Card, and IconButton components — covering everything from the core structure of the tv() function to Slots, extend, and compoundVariants. The biggest trade-off upfront: since this library depends on Tailwind, it's not a fit for projects that don't use it. Conversely, if you're already using Tailwind, the built-in class conflict resolution and TypeScript type inference make the adoption cost feel well worth it. As of 2025, the library sees roughly 1.48 million downloads per week and has proven itself in production as the backbone of the entire HeroUI (formerly NextUI) component system.
Core Concepts
Declare an entire component's styles with a single tv() function
The entry point of tailwind-variants is the tv() function. Inside it, you declare four structures — they may look unfamiliar at first, but each has a clear purpose and you'll get comfortable with them quickly.
import { tv } from "tailwind-variants";
const button = tv({
// 1. base: common classes always applied regardless of variant
base: "font-semibold rounded-full px-4 py-1 text-sm active:opacity-80",
// 2. variants: each variant key and its selectable values
variants: {
color: {
primary: "bg-blue-500 text-white hover:bg-blue-700",
secondary: "bg-purple-500 text-white hover:bg-purple-700",
ghost: "bg-transparent text-gray-700 border border-gray-300",
},
size: {
sm: "text-xs px-2 py-0.5",
md: "text-sm px-4 py-1",
lg: "text-base px-6 py-2",
},
// boolean variant: declaring a key as true/false controls it as a boolean
disabled: {
true: "opacity-50 pointer-events-none",
},
},
// 3. compoundVariants: applied only when a specific combination of variants is active
compoundVariants: [
{
color: "primary",
disabled: true,
class: "bg-blue-200",
},
],
// 4. defaultVariants: specify default values
defaultVariants: {
color: "primary",
size: "md",
},
});
// Returns a class string
button({ color: "secondary", size: "lg" });tailwind-merge built in:
tailwind-variantsinternally includestailwind-merge, so when classes controlling the same property — likep-2andpx-4— conflict, the latter takes precedence. This is handled automatically with no additional configuration.
With TypeScript, type inference is applied across all variant keys and values. Passing a non-existent variant value results in a compile error, which naturally documents the component API at the code level.
For compound components like Card — made up of a header, body, and footer — you declare each part using the slots option. You'll see the specifics right away in Example 1 below.
Practical Application
Example 1: Card Component — Combining Slots + compoundVariants
A card component's overall style changes depending on whether it's elevated or outlined, and both the inner text size and padding need to change together based on size. This is where the role of slots becomes clearly apparent in compound components.
import { tv, type VariantProps } from "tailwind-variants";
const card = tv({
slots: {
root: "rounded-xl border bg-white overflow-hidden transition-shadow",
header: "px-6 py-4 border-b font-semibold text-gray-900",
body: "px-6 py-4 text-gray-700",
footer: "px-6 py-4 border-t bg-gray-50",
},
variants: {
size: {
sm: { root: "max-w-sm", header: "text-sm py-3", body: "text-xs" },
md: { root: "max-w-md", header: "text-base", body: "text-sm" },
lg: { root: "max-w-lg", header: "text-lg py-5", body: "text-base" },
},
variant: {
elevated: { root: "shadow-md hover:shadow-xl" },
outlined: { root: "border-2 border-blue-200 shadow-none" },
flat: { root: "shadow-none border-transparent bg-gray-50" },
},
},
// Extra emphasis style applied to the header only for the elevated + lg combination
compoundVariants: [
{
variant: "elevated",
size: "lg",
class: { header: "text-xl font-bold" },
},
],
defaultVariants: {
size: "md",
variant: "elevated",
},
});
// Extract variant props type from the return type of tv()
type CardVariants = VariantProps<typeof card>;
type CardProps = CardVariants & {
title: string;
children: React.ReactNode;
footerContent?: React.ReactNode;
className?: string;
};
function Card({ size, variant, title, children, footerContent, className }: CardProps) {
const { root, header, body, footer } = card({ size, variant });
return (
// You can pass additional classes as an argument when calling a slot function
<div className={root({ class: className })}>
<div className={header()}>{title}</div>
<div className={body()}>{children}</div>
{footerContent && <div className={footer()}>{footerContent}</div>}
</div>
);
}When I first used this pattern, I was worried the prop name footer and the destructured footer from slots would cause a naming conflict, so I renamed it to footerSlot. Later I realized that naming the prop footerContent from the start eliminates the conflict entirely, making the code feel much more natural. It's a small thing, but an easy mistake to make.
| Element | Role |
|---|---|
slots |
Defines the base classes for each component part (root, header, body, footer) |
variants.size |
Declares rules for text size and padding across parts to change together based on size |
variants.variant |
Branches the card's visual style (shadow, border) |
compoundVariants |
Handles the special case of the elevated + lg combination separately |
root({ class: className }) |
Pattern for injecting additional classes from the outside when calling a slot function |
The core value of Slots: In a component made up of multiple sub-elements, you can maintain "when this variant, root looks like this, header looks like this..." as a single source of truth. If you split parts into separate
tv()calls, this coordination breaks.
Example 2: Button → IconButton — Building a Hierarchy with extend
When building a design system, you'll often need the pattern of extending from a Base component to a Specific component. With extend, you can inherit all existing variants while adding new ones.
import { tv, type VariantProps } from "tailwind-variants";
// Base: common styles for all buttons
const button = tv({
base: "font-semibold rounded-lg px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2",
variants: {
color: {
primary: "bg-blue-500 text-white hover:bg-blue-600",
danger: "bg-red-500 text-white hover:bg-red-600",
ghost: "bg-transparent text-gray-700 border border-gray-300 hover:bg-gray-50",
},
size: {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-6 py-3",
},
// boolean variant: controls disabled state with the true key
disabled: {
true: "opacity-50 pointer-events-none cursor-not-allowed",
},
},
defaultVariants: { color: "primary", size: "md" },
});
// IconButton: extends button — color, size, and disabled are all inherited
const iconButton = tv({
extend: button,
base: "inline-flex items-center gap-2",
variants: {
iconPosition: {
left: "flex-row",
right: "flex-row-reverse",
},
// boolean variant: overrides padding when only an icon is present
iconOnly: {
true: "px-2 aspect-square",
},
},
});
type ButtonProps = VariantProps<typeof button>;
type IconButtonProps = VariantProps<typeof iconButton>;
// Both button's color, size and iconButton's iconPosition are available
iconButton({ color: "primary", size: "lg", iconPosition: "left" });
iconButton({ color: "danger", iconOnly: true, size: "sm" });How
extendworks: It inheritsbase,variants, andcompoundVariantsfrom the parenttv()declaration, and merges in whatever the child declares. If the same key is re-declared, the child's value takes precedence.
Going a Step Further: Responsive Variants
The next two examples go one step beyond the Button and Card patterns above. They're not essential for basic use, but they're features that naturally become necessary as a project grows in scale.
import { createTV } from "tailwind-variants";
// To use the responsive variant object syntax, you must activate the option with createTV
const tv = createTV({
responsiveVariants: true,
});
const grid = tv({
base: "grid gap-4",
variants: {
cols: {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
},
},
});
// Responsive: 1 column on mobile → 2 columns on tablet → 3 columns on desktop
grid({ cols: { initial: "1", sm: "1", md: "2", lg: "3" } });
// → "grid gap-4 grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3"Note: The responsive object syntax like
cols: { initial: "1", md: "2" }requires theresponsiveVariantsoption to be enabled. Passing an object to a regulartv()without this option will not work as intended.
Going a Step Further: Connecting with Tailwind v4 Design Tokens
In Tailwind CSS v4, you declare CSS variable-based design tokens using the @theme block. Referencing these semantic tokens directly inside tailwind-variants variant classes connects the token hierarchy to your code.
/* globals.css — Tailwind v4 */
@import "tailwindcss";
@theme {
/* Base tokens: raw values */
--color-blue-500: #3b82f6;
/* Semantic tokens: purpose-based */
--color-brand-primary: var(--color-blue-500);
--color-brand-danger: var(--color-red-500);
}const button = tv({
base: "font-semibold rounded-lg px-4 py-2",
variants: {
intent: {
// Reference semantic tokens using the arbitrary value syntax with CSS variables
brand: "bg-[--color-brand-primary] text-white",
danger: "bg-[--color-brand-danger] text-white",
},
},
});v4 syntax note: In Tailwind CSS v4, a new parenthesis syntax
bg-(--var-name)was introduced for referencing CSS variables. The bracket syntaxbg-[--var-name]also works, but it's recommended to check the official documentation for the syntax matching your version of Tailwind before applying it.
| Layer | Tool | Example |
|---|---|---|
| Base tokens | CSS variables (@theme) |
--color-blue-500: #3b82f6 |
| Semantic tokens | CSS variable reference | --color-brand-primary: var(--color-blue-500) |
| Component variant | tailwind-variants |
"bg-[--color-brand-primary]" |
This structure systematizes at the code level the pipeline from Figma tokens → CSS variables → component variants. It naturally creates a flow where a change to a single design token is reflected consistently across all connected components.
Pros and Cons Analysis
Honestly, tailwind-variants is not the right tool for every project. Use the table below to judge whether it fits your current situation.
Pros
| Item | Details |
|---|---|
| Full TypeScript support | Type inference across all variant keys and values; automatically extract component types with VariantProps |
| tailwind-merge built in | Automatic class conflict resolution with no additional configuration |
| Slots | Manage an entire compound component within a single tv() declaration; maintains variant coordination between parts |
| extend | Compose a Base → Specific component hierarchy with variant inheritance |
| compoundVariants | Express complex states with styles that apply only to specific variant combinations |
| Framework agnostic | Works in any environment: React, Vue, Svelte, Solid, etc. |
| No runtime overhead | Only generates class strings; no runtime CSS injection |
Cons and Caveats
| Item | Details | Mitigation |
|---|---|---|
| TailwindCSS required dependency | When publishing as a library, consumer projects also need Tailwind configured | Adopt only for Tailwind-based projects; consider CVA as an alternative |
| Initial design cost | Combining slots, compoundVariants, and extend increases design complexity | Referencing HeroUI open-source code reduces the learning curve |
| Verbose class strings | Generated HTML class strings become long, reducing readability during debugging | Focus on computed styles in browser DevTools |
| Dynamic class purging | Dynamically generated classes may be removed by Tailwind's purge | Use safelist configuration, or declare variant values as complete class names |
Purge (PurgeCSS): Tailwind removes any classes not actually used in the build, keeping only the ones it finds. Dynamically concatenating classes like
"bg-" + colorcan cause those classes to disappear from the build output. When using tailwind-variants, it's safer to write complete class names like"bg-blue-500"directly as variant values.
The Most Common Mistakes in Practice
- Dynamically concatenating classes in variant values — Patterns like
"bg-" + colorNamewill be removed by Tailwind's purge. It's recommended to write complete class names like"bg-blue-500"directly as variant values. - Overusing compoundVariants — Putting every simple combination into compoundVariants makes declarations long and hard to trace. Reserve it for "styles that only make sense when two variant values are applied simultaneously."
- Declaring independent
tv()instances for each slot part — Splitting parts into separatetv()calls makes it impossible to coordinate styles between parts when a variant value changes. A single compound component should be contained within a singletv()declaration using slots.
Closing Thoughts
tailwind-variants preserves the flexibility of Tailwind utility classes while providing the declarativeness and type safety that a component's variant system demands.
If you're considering adopting it, here's a suggested approach:
- Pick a button component in your current project that manages variants with
clsxor ternary operators, install it withpnpm add tailwind-variants, and try migrating it totv(). SeeingVariantPropsautomatically bring along the types gives you a quick feel for the whole workflow. - Once you're comfortable with single-element components, try building one component made up of multiple parts — like a Card or Modal — using
slots. HeroUI's open-source code (github.com/heroui-inc/heroui) is a solid concrete reference for how slots and compoundVariants are combined in large-scale designs. - If you're building a design system, consider the three-layer token architecture: declaring semantic tokens in Tailwind v4's
@themeblock and referencing them via CSS variables in variant classes. Once the pipeline from Figma variables → CSS variables → variant classes is organized, a structure where a design token change is consistently reflected across all connected components comes together naturally.
References
- tailwind-variants official docs | tailwind-variants.org
- Variants API | tailwind-variants.org
- Slots | tailwind-variants.org
- Composing Components | tailwind-variants.org
- tailwind-variants GitHub | heroui-inc
- CVA vs. Tailwind Variants: Choosing the Right Tool for Your Design System | DEV Community
- Simplifying TailwindCSS with Tailwind Variants in React | DEV Community
- Scalable Tailwind Components: A How-To Guide | Hashnode
- Design Tokens That Scale in 2026 (Tailwind v4 + CSS Variables) | Mavik Labs
- HeroUI official site
- tailwind-variants | npm