Design Systems: Tokens, Components, and Governance
Understand how a design system organizes tokens, typography, spacing, components, and documentation so that a product's UI stays visually consistent, accessible, and maintainable as it scales across teams.
A design system is the contract between design and engineering: a single source of truth for colors, type, spacing, and components that everyone consumes instead of reinventing. In React, that contract usually takes the shape of a token layer (raw values), a semantic layer (meaningful names built from tokens), and a component layer (React components that consume the semantic layer through props and CSS variables). This lesson builds that stack from the ground up — tokens, typography scales, spacing scales, component variant APIs, accessibility requirements, documentation practices, and the versioning discipline that keeps a growing component library from breaking every app that depends on it.
Design tokens: the atomic layer
Design tokens are named, platform-agnostic values — colors, font sizes, spacing units, border radii, shadows, durations — that replace hard-coded values scattered through a codebase. Instead of writing color: #2563eb in twenty components, every component references a token like --color-primary-600, and changing the token updates the entire product in one place.
Tokens are usually organized in two tiers. Primitive (or 'global') tokens are raw values with no semantic meaning: blue-100 through blue-900, space-1 through space-12. Semantic (or 'alias') tokens map primitives to a purpose: --color-text-primary: var(--blue-900), --color-surface-danger: var(--red-100). Components should consume semantic tokens, never primitives directly, because the semantic layer is what lets you re-theme the whole product (dark mode, a rebrand, a white-label variant) by remapping aliases without touching component code.
In a TypeScript + React codebase, tokens are commonly exported as a typed object (for JS-side access, e.g. inline style calculations or chart libraries) and mirrored as CSS custom properties (for stylesheet and CSS-in-JS access). Keeping both in sync — often via a code generator run against a single JSON/YAML token source — avoids the two layers drifting apart.
• Primitive tokens: raw palette and scale values with no semantic meaning attached. • Semantic tokens: purpose-named aliases (text-primary, surface-danger, border-focus) that components actually consume. • Component tokens: the narrowest tier — values scoped to one component, e.g. --button-height-md, useful when a component needs an override that shouldn't leak into the semantic layer. • A single source of truth (JSON/YAML) generates both the TypeScript token object and the CSS custom properties, so design and code never drift.
Typography and spacing scales
A typography scale defines a fixed, finite set of font sizes, line heights, and weights — typically 6 to 9 steps on a modular scale (e.g. ratio 1.25: 12, 14, 16, 20, 25, 31, 39px) — so that every heading and body style in the product comes from the same rhythm instead of ad hoc pixel values. Each step is usually named semantically too: text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, mapped to a heading/body/caption usage table so engineers don't have to guess which size to use where.
A spacing scale works the same way but for margin, padding, and gap: a small set of steps (often a 4px or 8px base unit, e.g. 4, 8, 12, 16, 24, 32, 48, 64) that every component's internal and external spacing must snap to. Restricting spacing to a scale — rather than allowing arbitrary pixel values — is what makes a product's whitespace feel deliberate rather than accidental, and it is enforceable with a lint rule that flags raw pixel values in styles.
Both scales should be exposed as tokens (--font-size-lg, --space-4) and as TypeScript types (a union of allowed values) so that a component's size prop can only ever be one of the scale's valid steps — the type system becomes an extension of the design system's constraints.
Component variants and composable APIs
Design systems express visual variation through a small, closed set of props rather than free-form styling: a variant prop (primary | secondary | ghost | danger), a size prop (sm | md | lg), and boolean modifiers (fullWidth, disabled, loading). This is what lets a component look consistent everywhere it's used while still covering the real range of UI needs.
In TypeScript, these variant sets are modeled as string literal unions, which gives autocomplete in editors and compile-time errors for typos — variant="primry" fails to compile rather than silently rendering the wrong style. Many teams pair this with a utility like class-variance-authority (cva) or a hand-rolled variant map to keep the mapping from prop values to class names declarative and centrally defined instead of scattered through conditional class-name logic in every component.
A well-designed component API separates presentation props (variant, size, tone) from content props (children, label) from behavior props (onClick, disabled, loading) — this separation is what a later lesson on headless components pushes even further by removing presentation entirely.
Accessibility as a system-level requirement
Accessibility cannot be bolted on per-component after the fact at scale — it has to be a property of the system itself. That means: every interactive primitive in the library (button, input, dialog, menu) implements correct semantics and keyboard behavior once, centrally, so that every product surface built from it inherits WCAG-conformant behavior automatically instead of each team re-deriving it.
Concretely this includes: sufficient color contrast baked into the token palette (verified programmatically, not just eyeballed), focus-visible styles defined once at the token/component level, ARIA roles and attributes wired into components rather than left to consumers, and keyboard interaction patterns (Tab, Escape, Arrow keys, Enter/Space) implemented in the component itself, not as documentation telling consumers to add it themselves.
A mature design system publishes an accessibility contract per component — which WAI-ARIA pattern it follows, which keys it supports, what it announces to screen readers — and tests that contract in CI, often with axe-core, so a regression is caught before it ships.
Documentation, Storybook, and versioning
A component that isn't documented might as well not exist — consumers will either avoid it or misuse it. Storybook (or an equivalent like Ladle) is the standard tool for component documentation: each component gets one or more 'stories' that render it in isolation with different prop combinations, giving consumers a live, interactive catalog instead of relying on reading source code. Stories double as a visual regression testing surface and as the design team's review environment.
Good component documentation states, per component: the full typed prop API with defaults, accessibility notes (keyboard support, ARIA roles), usage guidance (when to use this vs. a similar component), and do/don't examples. Autogenerating the prop table from TypeScript types (via tools like react-docgen-typescript) keeps documentation from silently drifting out of sync with the actual API.
Versioning matters because a design system is a dependency many teams consume simultaneously, often at different versions. Semantic versioning (major.minor.patch) communicates impact: patch releases fix bugs with no API change, minor releases add capability without breaking existing usage (a new optional prop, a new variant), and major releases signal breaking changes (a renamed prop, a removed variant, a changed default) that require consumers to read a migration guide before upgrading. Systems at scale also publish a deprecation policy — old APIs keep working with a console warning for at least one full major cycle before removal — so consuming teams aren't forced into synchronized, all-or-nothing upgrades.
• Storybook stories document usage and double as visual regression / accessibility test fixtures. • Autogenerate prop tables from TypeScript types so docs cannot drift from the real API. • Semantic versioning (major.minor.patch) communicates breaking vs. additive vs. fix changes. • A deprecation window (old API still works, with a warning, for one major cycle) lets consuming teams upgrade on their own schedule.
Modeling a token system and a typed variant prop in TypeScript
as const on the token objects narrows values to literal types instead of widening to string/number, so downstream code gets precise autocomplete. Components should import from semanticTokens, never primitives, to keep the re-themeable boundary intact.
// tokens.ts — primitive + semantic layers, single source of truth
export const primitives = {
blue: { 100: "#dbeafe", 600: "#2563eb", 900: "#1e3a8a" },
red: { 100: "#fee2e2", 600: "#dc2626" },
space: { 1: "4px", 2: "8px", 3: "12px", 4: "16px", 6: "24px", 8: "32px" },
} as const;
export const semanticTokens = {
colorTextPrimary: primitives.blue[900],
colorSurfaceBrand: primitives.blue[600],
colorSurfaceDanger: primitives.red[600],
spaceInsetMd: primitives.space[4],
} as const;
// A component's variant surface is a closed literal union, not a string
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps {
variant?: ButtonVariant; // defaults documented in Storybook + prop table
size?: ButtonSize;
}Code Examples
The component's stylesheet never hard-codes a color or spacing value — it only references custom properties. Swapping [data-theme="dark"] in at the root re-themes every component that follows this pattern, with zero component code changes.
[data-theme="dark"] { --color-text-primary: #dbeafe;... } class-variance-authority centralizes the mapping from variant/size prop values to class names, and VariantProps derives the TypeScript prop types directly from that single declaration — eliminating the class of bug where the style map and the prop types are edited separately and fall out of sync.
Common mistake: Declaring variant: string by hand instead of deriving it from the style map lets variant="primry" compile and silently render unstyled — always derive prop types from the single source of truth.
/* tokens.css — generated from tokens.ts by a build script, one file per theme */
:root {
--color-text-primary: #1e3a8a;
--color-surface-brand: #2563eb;
--color-surface-danger: #dc2626;
--space-4: 16px;
--radius-md: 8px;
}
[data-theme="dark"] {
--color-text-primary: #dbeafe;
--color-surface-brand: #3b82f6;
}
/* Button.module.css */
.button {
padding: var(--space-4);
border-radius: var(--radius-md);
color: white;
}
.primary { background: var(--color-surface-brand); }
.danger { background: var(--color-surface-danger); }import { cva, type VariantProps } from "class-variance-authority";
const buttonStyles = cva("btn-base", {
variants: {
variant: {
primary: "btn-primary",
secondary: "btn-secondary",
ghost: "btn-ghost",
danger: "btn-danger",
},
size: {
sm: "btn-sm",
md: "btn-md",
lg: "btn-lg",
},
},
defaultVariants: { variant: "primary", size: "md" },
});
// VariantProps<typeof buttonStyles> derives the prop types FROM the style
// map, so the design-system's variant list and the component's TS types
// can never drift apart — there is exactly one place that defines "primary".
type ButtonStyleProps = VariantProps<typeof buttonStyles>;
function Button({ variant, size, className, ...rest }: ButtonStyleProps & React.ComponentPropsWithoutRef<"button">) {
return <button className={buttonStyles({ variant, size, className })} {...rest} />;
}Design system layers
Value flows downward (tokens → components → docs); a rebrand or dark theme is a change at the semantic layer only, and never touches component code.
┌─────────────────────────────────────────────┐
│ Primitive tokens blue-600, space-4, ... │ raw values, no meaning
└───────────────────────┬───────────────────────┘
│ aliased by
┌───────────────────────▼───────────────────────┐
│ Semantic tokens color-surface-brand, ... │ purpose-named
└───────────────────────┬───────────────────────┘
│ consumed by
┌───────────────────────▼───────────────────────┐
│ Components <Button variant="primary" /> │ typed variant API
└───────────────────────┬───────────────────────┘
│ documented in
┌───────────────────────▼───────────────────────┐
│ Storybook stories + autogenerated prop docs │
└─────────────────────────────────────────────────┘Important Rules
Components consume semantic tokens, never primitive tokens directly, so re-theming stays a one-layer change.
Every variant/size prop is a closed TypeScript literal union derived from a single style-map source, not a free-form string.
Accessibility (contrast, focus states, ARIA, keyboard support) is implemented once in the shared component, never left as guidance for consumers to re-implement.
Every published component has a Storybook story and an autogenerated prop table before it is considered released.
Breaking changes bump the major version and ship with a migration guide and a deprecation warning period.
Why large orgs invest in this
Companies like Shopify (Polaris), Atlassian (Atlassian Design System), and GitHub (Primer) maintain public design systems precisely because, past a few dozen engineers, uncoordinated component-building becomes a bigger cost than the system itself: duplicated accessibility bugs, inconsistent spacing that erodes user trust, and weeks lost per team reinventing a date picker. The token → component → documentation → versioning pipeline described in this lesson is the same pipeline these production systems run, just at larger scale.
background-color: var(--color-surface-brand);
Hard-coded values can't be re-themed and silently diverge from the palette over time as engineers eyeball 'close enough' colors; token references keep every consumer visually consistent and themeable from one place.
function Button({ variant }: { variant: string }) {... } function Button({ variant }: { variant: ButtonVariant }) {... } // ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
A bare string type accepts any typo silently at compile time; a literal union turns an invalid variant into a compile error instead of a runtime styling bug discovered in production.
Why should components consume semantic tokens (e.g. --color-surface-brand) rather than primitive tokens (e.g. --blue-600) directly? A. Semantic tokens are faster for the browser to parse B. It keeps re-theming (dark mode, rebrand) a one-layer change instead of requiring every component to be edited C. Primitive tokens cannot be used in CSS custom properties D. TypeScript cannot type-check primitive token names
B. It keeps re-theming (dark mode, rebrand) a one-layer change instead of requiring every component to be edited — The semantic layer is the indirection point: remapping --color-surface-brand to a new primitive re-themes every component that references it, without touching component code.
What is the main advantage of deriving a component's variant prop types from a cva style map with VariantProps<typeof styles> instead of writing variant: string? A. It makes the bundle smaller B. It removes the need for a size prop C. It guarantees the TypeScript types and the actual style mapping can never drift apart, so invalid variants fail to compile D. It automatically generates Storybook stories
C. It guarantees the TypeScript types and the actual style mapping can never drift apart, so invalid variants fail to compile — Deriving types from the single style-map source means there is exactly one place that defines the valid variant set; a bare string type would silently accept typos at compile time.
In semantic versioning for a design system package, what does a major version bump signal to consumers? A. A bug fix with no API change B. A breaking change (e.g. a renamed prop or changed default) that requires reading a migration guide before upgrading C. A purely cosmetic documentation update D. An internal refactor with no observable effect
B. A breaking change (e.g. a renamed prop or changed default) that requires reading a migration guide before upgrading — Major bumps in semver signal breaking changes; minor bumps signal additive, backward-compatible changes; patch bumps signal fixes with no API change.
Define a two-tier token object (primitive + semantic) for a small palette (background, text, border, danger) and spacing scale, then write a typed Tag component with variant: "neutral" | "success" | "danger" and size: "sm" | "md" props whose styles are derived from a single cva-style variant map so the TypeScript types and the style map cannot drift apart.
const tagStyles = cva('tag-base', { variants: { variant: { neutral: 'tag-neutral', success: 'tag-success', danger: 'tag-danger' }, size: { sm: 'tag-sm', md: 'tag-md' } }, defaultVariants: { variant: 'neutral', size: 'md' } }); type TagProps = VariantProps<typeof tagStyles> & React.ComponentPropsWithoutRef<'span'>; function Tag({ variant, size, className, ...rest }: TagProps) { return <span className={tagStyles({ variant, size, className })} {...rest} />; }
Start from the semantic token names, not the primitives — decide what the component needs to express (a neutral tag surface, a danger tag surface) before deciding which hex value backs it.