Ask an AI to generate a button component without any structure, and it will invent something. Ask it to generate a <Button variant="primary" size="lg"> with a defined API, and it will produce exactly that. The difference is component variants — and it's the single most impactful pattern for getting consistent AI output.
The Problem with Boolean Props
Design system components start simple and accumulate complexity. Six months into a project, a button component often looks like this:
<Button
primary
large
rounded
loading={false}
danger={false}
outline={false}
fullWidth={false}
disabled={false}
>
Click me
</Button>
Nine boolean props. Infinite invalid combinations. When AI generates this component, it invents combinations that don't exist — primary secondary large small, which is nonsensical. TypeScript can't catch it because each prop is independently valid.
The component variant pattern solves this by replacing boolean flags with mutually exclusive named options.
What Component Variants Are
Variants organize component configuration into axes — independent dimensions where you pick one option from a fixed set.
A button typically needs three axes:
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; // visual style
size?: 'sm' | 'md' | 'lg'; // dimensions
children: React.ReactNode;
};
Each axis is mutually exclusive. You can't be both primary and secondary. TypeScript enforces this at compile time. AI knows exactly which values are valid because they're enumerated in the type.
Implementing Variants with CVA
CVA (class-variance-authority) is the standard library for managing variants in Tailwind projects. It's 0.5kb and makes variant logic declarative:
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'rounded-md font-medium transition', // base classes, always applied
{
variants: {
variant: {
primary: 'bg-primary-500 hover:bg-primary-600 text-white',
secondary: 'bg-secondary-500 hover:bg-secondary-600 text-white',
outline: 'bg-transparent border border-primary-500 text-primary-500 hover:bg-gray-50',
ghost: 'bg-transparent hover:bg-gray-100 text-text-primary',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
type ButtonProps = VariantProps<typeof buttonVariants> & {
children: React.ReactNode;
};
export function Button({ variant, size, children }: ButtonProps) {
return (
<button className={buttonVariants({ variant, size })}>
{children}
</button>
);
}
TypeScript infers the valid options from buttonVariants. Pass variant="blue" and you get a compile error. Pass variant="primary" and it resolves to the correct Tailwind classes.
Why AI Produces Better Output with Variants
Without a structured API, AI generating a "primary button" might produce any of these:
<button className="bg-blue-500 text-white px-4 py-2 rounded">
<button type="primary" className="...">
<Button primary={true} color="primary" type="button">
Three different representations of the same concept, none of which match your actual component API.
With variants defined and exposed via design tokens or MCP:
{
"component": "button",
"variants": {
"variant": ["primary", "secondary", "outline", "ghost"],
"size": ["sm", "md", "lg"]
}
}
AI produces:
<Button variant="primary" size="md">Submit</Button>
<Button variant="ghost" size="sm">Cancel</Button>
The pattern also gives AI enough semantic information to make design decisions. When it sees description: "Low-emphasis actions, tertiary buttons" on the ghost variant, it knows to use ghost for cancel buttons and primary for the main call to action.
Compound Variants
Sometimes a specific combination of variants needs different styles than either variant alone would produce.
A small outline button (2px border) looks heavy at sm size. You want 1px there:
const buttonVariants = cva('rounded-md font-medium', {
variants: {
variant: {
primary: 'bg-primary-500 text-white',
outline: 'border-2 border-primary-500 text-primary-500',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
},
},
compoundVariants: [
{
variant: 'outline',
size: 'sm',
className: 'border', // overrides border-2 with border (1px)
},
],
});
<Button variant="outline" size="sm"> gets the compound class override. Every other combination uses the individual variant styles.
Naming Variants Semantically
Variant names should describe purpose, not appearance.
Avoid: variant: 'blue' | 'red' | 'gray'
These describe what the button looks like. When the brand color changes, the name becomes misleading. When AI sees variant="blue", it doesn't know if this is an informational action or a brand action.
Use: variant: 'primary' | 'destructive' | 'ghost'
These describe what the button does. destructive means "this is a dangerous action" regardless of what color your design system uses for danger states. AI can reason about when to use each variant from the name alone.
Keep variants small. Four to five options per axis is usually enough. If you need more, consider whether you need an additional axis (like intent) rather than more values on an existing axis.
Migrating from Boolean Props
If you have an existing component with boolean props, the migration path is straightforward.
Map each boolean to an axis:
primary,secondary,danger→variantaxislarge,small→sizeaxisloading→ keep as boolean (it's a state, not a visual variant)
Add deprecation warnings to the old props:
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
/** @deprecated Use variant="primary" instead */
primary?: boolean;
/** @deprecated Use size="lg" instead */
large?: boolean;
};
Migrate usages over time. The deprecated props still work, so nothing breaks immediately.
Testing All Combinations
Variants make exhaustive testing straightforward:
const variants = ['primary', 'secondary', 'outline', 'ghost'] as const;
const sizes = ['sm', 'md', 'lg'] as const;
describe('Button variants', () => {
variants.forEach(variant => {
sizes.forEach(size => {
it(`renders ${variant} ${size}`, () => {
const { container } = render(
<Button variant={variant} size={size}>Test</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
});
});
});
Twelve combinations covered automatically. Add a new variant and the loop picks it up without touching the test.
The variant pattern pays for itself quickly — cleaner API, better TypeScript coverage, more predictable AI output, and a test suite that grows with your component library automatically.