Tags: web-dev concept

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:

  1. Make it valid. Adjust the tokens so ghost-danger has adequate contrast. Usually possible, and best
  2. Constrain it in types, so the invalid pair doesn’t compile — Type Systems
  3. 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

  • tone or intent, not colour. The axis is about meaning; the colour is the implementation — Colour Systems
  • size values shared across the whole library. sm on a button and sm on an input should feel like the same size — Component API Design
  • Avoid type. It collides with HTML’s type attribute on buttons and inputs, which produces genuinely confusing bugs
  • Avoid primary as 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