Variants and Modifiers
Date: 2026-08-16
A component’s axes of variation, kept independent of each other. When two axes are tangled into one prop, the number of options multiplies instead of adding — and that multiplication is what makes component libraries collapse.
A variant is one value on one axis of a component’s variation. Keeping axes orthogonal means each can be chosen without affecting the others.
The multiplication problem
Tangled axes, as they usually arrive:
variant="primary"
variant="primary-small"
variant="primary-small-outline"
variant="secondary-small-outline"
variant="danger-outline"
…
Three axes with 4 × 3 × 2 values = 24 named strings, of which someone has defined eleven and the other thirteen are missing.
Separated:
tone="primary" | "secondary" | "danger" | "neutral"
size="sm" | "md" | "lg"
fill="solid" | "outline"
4 + 3 + 2 = 9 values, and all 24 combinations exist automatically. Adding a fourth tone adds one value and yields six new combinations for free.
TANGLED options = a × b × c (defined by hand)
ORTHOGONAL options = a + b + c (combinations free)
Finding the axes
Ask what varies independently:
BUTTON
tone what it means primary, danger…
size how big sm, md, lg
fill visual weight solid, outline, ghost
width layout auto, full
not axes:
loading ← a STATE, not a variant
disabled ← a STATE
icon ← CONTENT, use composition
States and content aren’t variants — conflating them is the other common error. variant="loading" makes loading mutually exclusive with primary, which it isn’t — Component States, Composition over Configuration.
When axes genuinely aren’t orthogonal
Sometimes a combination is invalid — fill="ghost" with tone="danger" may fail contrast.
Options, in order of preference:
- Make it valid. Adjust the tokens so ghost-danger has adequate contrast. Usually possible, and best
- Constrain it in types, so the invalid pair doesn’t compile — Type Systems
- Document it as discouraged, and accept it will happen
What not to do is invent a fused name to dodge the problem. That’s how you get back to 24 strings.
Implementing
The pattern most libraries converge on — a base plus per-axis maps:
base shared by every button
tone primary → bg action, text on-action
danger → bg danger, text on-danger
size sm → space.2 / font.sm
md → space.3 / font.base
fill solid → (base)
outline → transparent bg,
border currentColor
Each axis only sets the properties it owns. When fill and tone both set background, the order of application decides the result and you have a bug that appears in one combination out of twenty-four.
Values come from tokens, never literals, so a rebrand doesn’t touch this file — Design Tokens.
Naming
toneorintent, notcolour. The axis is about meaning; the colour is the implementation — Colour Systemssizevalues shared across the whole library.smon a button andsmon an input should feel like the same size — Component API Design- Avoid
type. It collides with HTML’stypeattribute on buttons and inputs, which produces genuinely confusing bugs - Avoid
primaryas a size and a tone. Reusing a word across axes makes both harder to read
Where it goes wrong
- An axis with one value.
fill="solid"and nothing else isn’t an axis; it’s a prop waiting to be a problem. Add it when the second value exists - Too many axes. Five axes is 100+ combinations, most untested and some visually broken. Three is usually right
- Per-consumer variants.
tone="checkout-special"is a fork wearing a variant’s name — Design System Governance - No visual test of the matrix. The combinations you never look at are exactly where the bugs live. A page rendering the full grid catches them in one glance, and it’s cheap to generate — Component Documentation