Why You Should Adopt the ‘Component-First’ Design System in React
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.
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.
Quick reference
- Tokens:
--color-danger,--space-3,--radius-md— named, never a literal hex value inside a component file. - Primitives:
Button,Input,Text— exposevariant/sizeprops, not arbitrarystyleoverrides. - 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.
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
dangeraction — a delete button a keyboard user can't see focused is a real defect, not a nitpick. - Never allow an inline
styleoverride 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.
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
Toastqueue, 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.
Related Articles
Explore this topic