Skip to content

Why You Should Adopt the ‘Component-First’ Design System in React

Core Concept LearningAugust 3, 20266 min read

A support engineer asks design to make the "delete account" button more clearly dangerous, so the team darkens the red on that one page. Three weeks later, someone notices the checkout page's error state uses a different red, the settings danger zone uses a third, and nobody remembers deciding any of that — each page's button just accumulated its own slightly different choice because there was never one place that owned the color. That is the failure mode a component-first design system exists to prevent: not "our UI looks inconsistent" as an aesthetic complaint, but "we cannot make one decision and have it apply everywhere" as an engineering cost.

This guide builds a component-first system around one running example — a danger variant that has to look and behave identically on a delete-account button, a checkout error banner, and a settings danger zone — through four layers: design tokens, primitive components, composed components, and page-level patterns. Each layer adds constraints on top of the last, and the payoff is that a single token change propagates to every consumer without touching a single page file. For the broader React architecture this system lives inside, see Server vs client components.

Layers of a component-first design system
Layers of a component-first design system

Four layers, each with a narrower job

A component-first system is a strict hierarchy, not a flat component library. Design tokens are the raw values — a color, a spacing unit, a radius — expressed as named variables, never as hex codes scattered through component files. Primitives are the smallest reusable pieces (Button, Input, Text) that consume tokens and expose a constrained API (variant, size) rather than raw style props. Composed components combine primitives into something with its own behavior (a Card with a header and actions, a FormField pairing a label with an input and its error state). Patterns are page-level arrangements of composed components for a specific job — a checkout form, a settings danger zone.

The rule that makes the hierarchy actually hold is one-directional dependency: patterns depend on composed components, composed components depend on primitives, primitives depend on tokens — never the reverse, and never a page reaching past a pattern to restyle a primitive directly for "just this one case." That last exception is how the three different reds happened in the first place.

A token change flowing through the system
A token change flowing through the system

Quick reference

  • Tokens: --color-danger, --space-3, --radius-md — named, never a literal hex value inside a component file.
  • Primitives: Button, Input, Text — expose variant/size props, not arbitrary style overrides.
  • Composed components: Card, FormField, Toast — own their internal layout of primitives, expose a narrower API than the primitives they wrap.
  • Patterns: CheckoutForm, DangerZone — compose composed components for one specific page job; pages assemble patterns, they don't invent new styling.
  • One-directional dependency is the whole discipline — a page reaching down to restyle a primitive "just this once" is the crack every inconsistency grows from.

Remember this

The hierarchy only holds if dependencies flow one direction — the moment a page restyles a primitive directly instead of going through a pattern, you've reintroduced the exact per-page drift the system exists to prevent.

A variant is a contract, not a color swap

The danger variant on Button is not "make it red" — it's a documented contract: which token it reads, what its hover and disabled states look like, and what accessible contrast ratio it guarantees. Define that contract once on the primitive, and the delete-account button, the checkout error action, and the settings danger zone all get it automatically because they all render the same Button with variant="danger" — there's no second place for the definition to drift.

The mistake that reintroduces the original bug is treating variant as a thin wrapper around inline styles instead of a real branch with its own tested states. A danger variant that only defines the resting color, with hover and disabled left to whatever the browser default happens to be, will look correct in a screenshot and inconsistent the moment someone actually hovers or disables it on two different pages.

Zoom: one Button variant contract
Zoom: one Button variant contract

Quick reference

  • Define hover, disabled, and focus-visible states in the same place as the resting color — a partial contract invites per-page improvisation.
  • Accessible focus rings matter as much as color for a danger action — a delete button a keyboard user can't see focused is a real defect, not a nitpick.
  • Never allow an inline style override on a primitive in application code — if a page needs something the contract doesn't support, that's a signal to add a variant, not bypass one.
  • Snapshot-test the variant's rendered class list, not just its visual appearance, so a refactor that silently drops the disabled state fails CI.
Thin variant — resting color only
1type ButtonVariant = "primary" | "danger";2 3export function Button({4  variant = "primary",5  children,6}: {7  variant?: ButtonVariant;8  children: React.ReactNode;9}) {10  const color = variant === "danger" ? "var(--color-danger)" : "var(--color-primary)";11  return <button style={{ backgroundColor: color }}>{children}</button>;12  // No hover, no disabled, no focus state — every consumer improvises its own.13}
Full variant contract: rest, hover, disabled, focus
1type ButtonVariant = "primary" | "danger";2type ButtonSize = "sm" | "md";3 4const variantStyles: Record<ButtonVariant, string> = {5  primary: "btn-primary",6  danger: "btn-danger",7};8 9export function Button({10  variant = "primary",11  size = "md",12  disabled,13  children,14  ...rest15}: {16  variant?: ButtonVariant;17  size?: ButtonSize;18  disabled?: boolean;19  children: React.ReactNode;20} & React.ButtonHTMLAttributes<HTMLButtonElement>) {21  return (22    <button23      className={`btn ${variantStyles[variant]} btn-${size}`}24      disabled={disabled}25      aria-disabled={disabled}26      {...rest}27    >28      {children}29    </button>30  );31}32 33/* btn-danger: bg var(--color-danger); hover: var(--color-danger-hover);34   disabled: opacity .5, cursor not-allowed; focus-visible: 2px outline35   var(--color-danger) offset 2px — all defined once, here. */36 37// Expected: <Button variant="danger">Delete account</Button> and the38// checkout error action render identical hover/disabled/focus behavior.39// Break it: add a third page that sets an inline backgroundColor override —40// that page now silently diverges from the contract on the next token change.

Remember this

A variant that only defines its resting color is not a contract — it's an invitation for every consumer to improvise hover, disabled, and focus behavior differently, which is exactly how three different "reds" end up in production.

Proving the payoff: one token, every consumer

The entire argument for this architecture collapses to one demonstrable fact: changing --color-danger in the token file should update the delete-account button, the checkout error banner, and the settings danger zone in the same pull request, without touching any of those three page files. If that's not true — if any of them hard-coded a color instead of consuming the token through the primitive — the system has already started to erode.

This is also the test to run before declaring the system "done." Grep the codebase for the danger color's literal hex value outside the token definition; any hit is a page that bypassed the contract and needs to be migrated back onto the primitive before the next incident repeats the original bug.

Quick reference

  • Treat a token change as a single-file diff that should visibly update every consumer — if it doesn't, something bypassed the primitive.
  • Run a periodic grep for raw color/spacing literals outside the token file as a lightweight audit, not a one-time migration.
  • New composed components and patterns should be reviewed for "does this introduce a new primitive variant, or reuse an existing one" — variant sprawl is its own form of drift.
  • Document the contract (states, accessibility guarantees) next to the primitive's source, not in a separate design tool that engineers don't open.
  • If a composed component needs shared interactive state (an open Toast queue, a selected tab), keep that state ownership as disciplined as the token hierarchy — see Observable state management in React.

Remember this

The system is working exactly when a single token-file change propagates to every consumer in one diff — the moment that stops being true for any component, that component has silently opted out of the design system.

Key takeaway

Build the danger variant end to end: define --color-danger and its hover/disabled/focus counterparts as tokens, implement the full contract on Button, and render it in three places — a delete-account button, a checkout error action, and a settings danger zone. Confirm all three look and behave identically, including keyboard focus.

Then change --color-danger once in the token file and confirm all three update without touching any of the three page files — that's your expected success. Break it deliberately: have one of the three pages set an inline style override on the button instead of using the variant, change the token again, and confirm that one page now visibly diverges from the other two. Pass criterion: the token change alone updates every contract-following consumer, and the bypassed page is the only one left behind — proving the divergence, not just describing it.

Share:

Related Articles

Next.js 16 continues the evolution of web application architecture, refining React Server Components (RSC), introducing

Read

Managing complex, high-frequency state updates in large React applications using traditional Redux or React Context lead

Read

Next.js 16 introduces powerful performance primitives for modern React applications. With refined React Server Component

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.