Skip to main content
react

The Prop I Regret Adding Is Always the One Called variant

The variant prop encodes deferred design decisions into your API — and you pay for that delay every time it grows. Here's what to reach for instead.


5 min read

The variant prop is almost always a sign that a design decision was not made. Not that it was made badly — not made. "We need a primary button and a secondary button" becomes <Button variant="primary"> and <Button variant="secondary"> because that feels flexible. Three months later there are eight variants, two of them overlap in appearance, one is only used in one place, and adding a ninth requires a prop change to a shared component. This is the variant trap.


What variant usually encodes

Most variant props encode one of two things: hierarchy (primary, secondary, tertiary) or visual treatment (contained, outlined, ghost, text). Both are legitimate design concepts. The problem is that encoding them in a single variant prop conflates two orthogonal dimensions.

A "secondary outlined button" requires either a variant="secondary-outlined" (combinatorial explosion) or two props: variant="secondary" and appearance="outlined" (now you have two under-specified props instead of one). Neither is satisfying, and both are symptoms of the original decision: using a prop to represent a design choice that was not fully resolved.

When variant is the right call

A variant prop is correct when the options are genuinely mutually exclusive, the set is closed, and the visual differences are total — not just one property changing. An alert component with variant="error" | "warning" | "success" | "info" is a valid use of the pattern because:

  • These states are semantically distinct, not visually distinct
  • The icon, color, and ARIA role all change together as a unit
  • You would never want "warning outlined" or "error ghost"
  • The set of valid values is unlikely to grow

A button with variant="primary" | "secondary" | "destructive" starts valid and becomes invalid as soon as design asks for a ghost variant of the destructive button, or a primary button that is also full-width, or a secondary button in a sidebar that uses different padding.

The alternative: separate components

The most durable solution is separate components:

// Instead of:
<Button variant="primary">Save</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="destructive">Delete</Button>

// Write:
<PrimaryButton>Save</PrimaryButton>
<GhostButton>Cancel</GhostButton>
<DestructiveButton>Delete</DestructiveButton>

This looks verbose, but it is not. Each component has its own file, its own default props, its own default styling. When design asks for "destructive buttons should have a confirmation dialog by default," you add that to DestructiveButton — not to a Button component with a flag. When GhostButton needs a different hover state on mobile, you change one component, not one branch of a multi-variant switch.

The common logic lives in a BaseButton that is not exported or used directly. This is composition over configuration, and it scales better than variant strings.

The alternative: CSS custom properties for visual variants

When the difference between variants is purely visual — color, border, background — CSS custom properties are cleaner than a JavaScript prop:

// The component
function Button({ className, children, ...props }: ButtonProps) {
  return (
    <button
      className={cn('rounded px-4 py-2 font-medium', className)}
      style={{
        '--btn-bg': 'var(--color-primary)',
        '--btn-color': 'white',
        '--btn-border': 'transparent',
      } as React.CSSProperties}
      {...props}
    >
      {children}
    </button>
  )
}

// Usage — no prop, caller overrides custom properties via className or style
<Button style={{ '--btn-bg': 'transparent', '--btn-color': 'currentColor', '--btn-border': 'currentColor' }}>
  Ghost
</Button>

This is more appropriate in a design system context where consumers are expected to theme components. The API surface stays minimal — no variant prop to maintain, no switch statement, no exhaustive type union to update. Callers apply their own visual treatment through the CSS layer.

The refactoring

When you inherit a component with six variants and need to add a seventh:

// Before
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline' | 'link'

<Button variant="primary" />   // used 120 times
<Button variant="destructive" />  // used 8 times, always in confirm dialogs

The right move is to split the high-frequency variants into their own components and leave the low-frequency ones as-is:

// After
<PrimaryButton />   // wraps Button with fixed props
<DestructiveButton />  // wraps Button, adds confirm dialog behavior
<Button variant="ghost" | "outline" | "link" />  // remaining shared variants

You did not remove the variant prop — you stopped adding to it. The existing variants stay as they are. New requirements get separate components. The combinatorial problem stops growing.

The prop you regret is always the flexible one. variant promises flexibility and delivers an unbounded enumeration of design decisions serialized as strings. Name the thing precisely or make it a component. The variant can wait.