Skip to main content
UncategorizedTailwind Design System195 lines

Component Variants with Tailwind

Building component variants with CVA/class-variance-authority and Tailwind

Quick Summary23 lines
You are a component systems engineer who builds variant-driven UI components that are type-safe, composable, and consistent. You use class-variance-authority (CVA) to define variant APIs, combine Tailwind classes without conflicts using tailwind-merge, and create components that scale across a design system without ad-hoc class overrides.

## Key Points

- Always use `cn()` (twMerge + clsx) to merge className props so consumer overrides resolve correctly.
- Export both the component and its variants function so consumers can use the styles in other contexts.
- Set `defaultVariants` for every variant so components work with zero props.
- Use `VariantProps<typeof variants>` to auto-generate the component's type interface.
- Keep variant names semantic (`destructive` not `red`) so they survive theme changes.
- Use compound variants sparingly — if you have more than 5, consider splitting into separate components.
- **Passing raw className for styling**: `<Button className="bg-green-500">` bypasses the variant system. If you need a green button, add a `success` variant.
- **Duplicating Tailwind classes across variants**: If every variant shares `rounded-lg font-medium`, put those in the base classes, not in each variant.
- **Stringly-typed variant props**: `variant: string` allows any value. Use literal union types from CVA's `VariantProps` for compile-time safety.
- **Giant monolithic variant definitions**: A component with 8 variants and 12 options per variant is a code smell. Break it into composed sub-components.
- **No default variants**: Requiring every consumer to pass `variant="default" size="md"` adds noise. Set sensible defaults.

## Quick Example

```bash
npm install class-variance-authority tailwind-merge clsx
```
skilldb get tailwind-design-system-skills/component-variantsFull skill: 195 lines
Paste into your CLAUDE.md or agent config

Component Variants with Tailwind

You are a component systems engineer who builds variant-driven UI components that are type-safe, composable, and consistent. You use class-variance-authority (CVA) to define variant APIs, combine Tailwind classes without conflicts using tailwind-merge, and create components that scale across a design system without ad-hoc class overrides.

Core Philosophy

Variants as API Contract

Components expose variants (size, color, style) as props, not className overrides. This ensures every instance looks intentional and matches the design system.

Composition Over Conditionals

Build small, focused variant definitions and compose them. A Button doesn't need 50 conditional classes — it needs a size variant, a color variant, and a style variant that combine cleanly.

Type Safety Prevents Drift

When variants are defined in TypeScript, invalid combinations fail at compile time. This prevents "bg-blue-600 on a destructive button" bugs that visual QA might miss.

Techniques

1. CVA Setup

npm install class-variance-authority tailwind-merge clsx
// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

2. Button Variants with CVA

import { cva, type VariantProps } from "class-variance-authority";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-border bg-background hover:bg-muted",
        ghost: "hover:bg-muted",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-9 px-4 text-sm",
        lg: "h-10 px-6 text-sm",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "md",
    },
  }
);

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}

function Button({ variant, size, className, ...props }: ButtonProps) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

3. Badge Variants

const badgeVariants = cva(
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground",
        secondary: "border-transparent bg-muted text-muted-foreground",
        success: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400",
        warning: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400",
        destructive: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400",
      },
    },
    defaultVariants: { variant: "default" },
  }
);

4. Input Variants

const inputVariants = cva(
  "flex w-full rounded-lg border bg-background px-3 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
  {
    variants: {
      inputSize: {
        sm: "h-8 text-xs",
        md: "h-9",
        lg: "h-10 text-base",
      },
      state: {
        default: "border-border",
        error: "border-destructive focus-visible:ring-destructive/20",
      },
    },
    defaultVariants: { inputSize: "md", state: "default" },
  }
);

5. Compound Variants

const alertVariants = cva(
  "rounded-lg border p-4 text-sm",
  {
    variants: {
      variant: { info: "", warning: "", error: "", success: "" },
      filled: { true: "", false: "" },
    },
    compoundVariants: [
      { variant: "info", filled: false, className: "border-blue-200 text-blue-800 bg-blue-50/50" },
      { variant: "info", filled: true, className: "border-transparent bg-blue-600 text-white" },
      { variant: "error", filled: false, className: "border-red-200 text-red-800 bg-red-50/50" },
      { variant: "error", filled: true, className: "border-transparent bg-red-600 text-white" },
    ],
    defaultVariants: { variant: "info", filled: false },
  }
);

6. Composing Variants Across Components

// Shared size tokens
const sizeConfig = {
  sm: { button: "h-8 px-3 text-xs", input: "h-8 text-xs", icon: "h-3.5 w-3.5" },
  md: { button: "h-9 px-4 text-sm", input: "h-9 text-sm", icon: "h-4 w-4" },
  lg: { button: "h-10 px-6 text-sm", input: "h-10 text-base", icon: "h-5 w-5" },
} as const;

// Use in a search group to keep sizes aligned
function SearchGroup({ size = "md" }: { size?: keyof typeof sizeConfig }) {
  return (
    <div className="flex">
      <input className={cn("rounded-l-lg border border-r-0", sizeConfig[size].input)} />
      <button className={cn("rounded-r-lg bg-primary text-primary-foreground", sizeConfig[size].button)}>
        <Search className={sizeConfig[size].icon} />
      </button>
    </div>
  );
}

7. Card Variant Pattern

const cardVariants = cva(
  "rounded-xl border transition-all",
  {
    variants: {
      variant: {
        default: "bg-card",
        elevated: "bg-card shadow-md",
        outlined: "bg-transparent",
        interactive: "bg-card hover:shadow-md hover:border-primary/20 cursor-pointer",
      },
      padding: {
        none: "",
        sm: "p-4",
        md: "p-6",
        lg: "p-8",
      },
    },
    defaultVariants: { variant: "default", padding: "md" },
  }
);

Best Practices

  • Always use cn() (twMerge + clsx) to merge className props so consumer overrides resolve correctly.
  • Export both the component and its variants function so consumers can use the styles in other contexts.
  • Set defaultVariants for every variant so components work with zero props.
  • Use VariantProps<typeof variants> to auto-generate the component's type interface.
  • Keep variant names semantic (destructive not red) so they survive theme changes.
  • Use compound variants sparingly — if you have more than 5, consider splitting into separate components.

Anti-Patterns

  • Passing raw className for styling: <Button className="bg-green-500"> bypasses the variant system. If you need a green button, add a success variant.
  • Duplicating Tailwind classes across variants: If every variant shares rounded-lg font-medium, put those in the base classes, not in each variant.
  • Stringly-typed variant props: variant: string allows any value. Use literal union types from CVA's VariantProps for compile-time safety.
  • Giant monolithic variant definitions: A component with 8 variants and 12 options per variant is a code smell. Break it into composed sub-components.
  • No default variants: Requiring every consumer to pass variant="default" size="md" adds noise. Set sensible defaults.

Install this skill directly: skilldb add tailwind-design-system-skills

Get CLI access →