Component Variants with Tailwind
Building component variants with CVA/class-variance-authority and 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. ## 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 linesComponent 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
defaultVariantsfor every variant so components work with zero props. - Use
VariantProps<typeof variants>to auto-generate the component's type interface. - Keep variant names semantic (
destructivenotred) 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 asuccessvariant. - 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: stringallows any value. Use literal union types from CVA'sVariantPropsfor 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
Related Skills
Accessibility Patterns
Focus styles, screen reader support, ARIA, and keyboard navigation with Tailwind
Animation & Motion Design
Transitions, keyframe animations, and spring-like animations with Tailwind
Design Token Color System
Design token color system with semantic colors, dark mode, and CSS variables in Tailwind
Dark Mode Implementation
Dark mode implementation with Tailwind dark:, CSS variables, and system preference detection
Responsive Design Patterns
Mobile-first responsive design, breakpoint strategy, and container queries with Tailwind
Spacing & Layout System
Spacing scale, grid systems, container patterns, and responsive layout utilities