Component Variants With CVA
Use the class-variance-authority library to define typed variant APIs for React components, keeping class logic clean and predictable.
Component Variants With CVA is a free Tailwind CSS Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Tailwind CSS Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
The Problem With Manual Variant Logic
Building component variant systems manually — with nested if statements and class maps — scales poorly. A button with 3 colors, 3 sizes, and a disabled state requires 18+ unique class combinations to handle correctly. The logic becomes sprawling and error-prone. Class Variance Authority (CVA) provides a structured, type-safe API for defining variant class maps, reducing variant logic to a clean declarative configuration.
Installing and Importing CVA
Install class-variance-authority with npm and import the cva function into your component file. CVA works alongside your existing cn() helper — CVA handles the variant logic, and tailwind-merge handles conflict resolution when caller classes are merged in. This combination is the foundation of libraries like shadcn/ui.
# Install
npm install class-variance-authority
# Also install companions if not present
npm install clsx tailwind-merge
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// In your component file
import { cva, type VariantProps } from 'class-variance-authority';Defining a Component With cva
Call cva() with two arguments: a string of base classes that always apply, and an options object with a variants map. Each key in variants is a prop name, and its value is an object mapping prop values to class strings. CVA merges the base classes with the matched variant classes when the returned function is called with prop values.
import { cva } from 'class-variance-authority';
const buttonVariants = cva(
// Base classes — always applied
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50 disabled:pointer-events-none',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
outline: 'border border-gray-300 bg-transparent hover:bg-gray-50',
ghost: 'hover:bg-gray-100 text-gray-700'
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base'
}
},
defaultVariants: {
variant: 'primary',
size: 'md'
}
}
);Using VariantProps for TypeScript
CVA exports the VariantProps utility type that automatically infers the correct TypeScript types for your variant props from the cva definition. This means TypeScript will error if you pass an invalid variant value — like variant='purple' when only primary, secondary, outline, and ghost are defined. You get full IntelliSense autocomplete for variant values in your editor.
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva('...', { variants: { /* ... */ } });
// VariantProps extracts the type automatically
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}Compound Variants
CVA supports compound variants — classes that apply only when a specific combination of variant values is active. For example, you might want a special style that applies only when both variant='primary' and size='lg' are used together. This is more expressive than writing nested conditionals and keeps all variant logic in one place.
const buttonVariants = cva('base-classes', {
variants: {
variant: {
primary: 'bg-blue-600 text-white',
destructive: 'bg-red-600 text-white'
},
size: {
sm: 'h-8 px-3 text-xs',
lg: 'h-12 px-6 text-base'
}
},
compoundVariants: [
{
// Apply only when BOTH conditions are true
variant: 'primary',
size: 'lg',
class: 'shadow-lg hover:shadow-xl'
},
{
variant: 'destructive',
size: 'lg',
class: 'ring-2 ring-red-300'
}
]
});Default Variants
The defaultVariants field in the cva options specifies which variant values are used when the corresponding prop is not provided. This makes most props optional while still producing correct output. A Button component with defaultVariants: { variant: 'primary', size: 'md' } renders a medium-sized primary button when used as <Button> with no props.
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800'
}
},
defaultVariants: {
variant: 'default' // <Badge /> without variant prop → default style
}
}
);
// All three are valid:
<Badge /> // uses default variant
<Badge variant='success' /> // green
<Badge variant='danger' /> // redCVA for Non-Button Components
CVA is not limited to buttons — use it for any component with multiple style variants. Cards can have elevated, outlined, and ghost variants. Inputs can have default and error states. Text elements can have h1 through h6 size variants. CVA's declarative API works equally well for any component where CSS classes vary based on props.
const cardVariants = cva(
'rounded-xl overflow-hidden',
{
variants: {
variant: {
elevated: 'bg-white shadow-md hover:shadow-lg transition-shadow',
outlined: 'bg-white border border-gray-200',
filled: 'bg-gray-50'
},
padding: {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8'
}
},
defaultVariants: {
variant: 'elevated',
padding: 'md'
}
}
);Composing CVA Variants
Multiple CVA definitions can be composed together. A base button style can be defined with cva, and a more specific IconButton can call the base cva function and merge its result with icon-specific classes. This composition model mirrors component composition in React and keeps each variant definition focused on a single concern.
// Base shared button classes
const baseButton = cva(
'inline-flex items-center justify-center font-medium transition-colors disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'hover:bg-gray-100'
}
}
}
);
// Icon button extends base
function IconButton({ icon, variant, 'aria-label': label }) {
return (
<button
className={cn(
baseButton({ variant }),
'h-10 w-10 rounded-full p-0' // icon-specific additions
)}
aria-label={label}
>
{icon}
</button>
);
}Exporting Variants for Reuse
In component libraries, it is common to export the cva function result (the variants function) alongside the component itself. This allows other components to apply the same variant classes without importing the full component. For example, a Link component might want to apply buttonVariants({ variant: 'primary' }) to render as a button visually while remaining a semantic anchor element.
// components/Button.tsx
export const buttonVariants = cva('...base...', {
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900'
},
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-12 px-6' }
}
});
export function Button({ variant, size, className, ...props }) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
// In another file: Link styled as a button
import { buttonVariants } from './Button';
<a href='/signup' className={cn(buttonVariants({ variant: 'primary', size: 'md' }))}>
Sign Up
</a>CVA With Responsive Variants
Tailwind's responsive prefixes can be included inside CVA class strings just like any other utility. This means you can define variants that include responsive behavior. For example, a layout variant might apply flex-col md:flex-row — the CVA system does not need to know about Tailwind's breakpoints; they are just class strings.
const stackVariants = cva(
'flex gap-4',
{
variants: {
direction: {
vertical: 'flex-col',
horizontal: 'flex-row',
responsive: 'flex-col sm:flex-row' // breakpoint in variant
},
align: {
start: 'items-start',
center: 'items-center',
end: 'items-end'
}
},
defaultVariants: {
direction: 'vertical',
align: 'start'
}
}
);
// Usage
<div className={stackVariants({ direction: 'responsive', align: 'center' })}>
{/* Children stack vertically on mobile, horizontally on sm+ */}
</div>Real-World CVA Component Library
A mature CVA setup defines all primitive components — Button, Badge, Input, Card, Alert — each with their own cva definitions. These components are collected into a components/ui folder (as seen in shadcn/ui), each file exporting both the React component and its variants function. This structure makes the variant system discoverable, testable, and easy to extend.
// components/ui/alert.tsx
import { cva, type VariantProps } from 'class-variance-authority';
export const alertVariants = cva(
'relative w-full rounded-lg border p-4 text-sm',
{
variants: {
variant: {
default: 'bg-white text-gray-900 border-gray-200',
destructive: 'bg-red-50 text-red-800 border-red-200',
success: 'bg-green-50 text-green-800 border-green-200'
}
},
defaultVariants: { variant: 'default' }
}
);
export function Alert({ variant, className, children, ...props }) {
return (
<div className={cn(alertVariants({ variant }), className)} role='alert' {...props}>
{children}
</div>
);
}Quick Check
Test your understanding of Tailwind CSS Mastery concepts from this lesson.
Lesson Recap
In this lesson you learned: cva() defines variant maps with base classes plus conditional class groups, VariantProps automatically derives TypeScript types from the cva definition, and compoundVariants apply classes only when specific combinations of variants are active. Next up we explore how tailwind-merge prevents class conflicts when composing components.
Frequently asked questions
Is the “Component Variants With CVA” lesson free?
Yes — the full text of “Component Variants With CVA” is free to read here on the web, and the Tailwind CSS Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Tailwind CSS Academy course, upgrade to CoddyKit PRO.
What will I learn in “Component Variants With CVA”?
Use the class-variance-authority library to define typed variant APIs for React components, keeping class logic clean and predictable. You practise Tailwind CSS Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Tailwind CSS Academy?
No prior experience is required. Tailwind CSS Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Component Variants With CVA” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Tailwind CSS Academy lesson?
Yes. Every Tailwind CSS Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Setting Up Tailwind in Next.js
- Conditional Classes in React
- Component Variants With CVA
- Avoiding Class Conflicts With tailwind-merge