cf.completefrontend
My progress
SYSTEM DESIGN · #127

Design a design system for many teams

A company-wide design system that 24 product teams use across React, Angular, server-rendered pages and native apps. Drawing a button is easy; the real work is one token source that feeds every platform and brand, components that split behavior from looks, styles that product code can override on purpose, versions and codemods that move 70 repos forward, quality gates that catch visual and accessibility regressions, and the governance that makes teams want to adopt it.

The question

Our company has grown to 24 product teams. Every team built its own buttons, modals and colors, so our apps look like five different companies and the same accessibility bugs get fixed again and again. Design the design system we should build, and tell us how you'd roll it out.

We have about 70 web repos: most are React (Next.js and Vite), a few are Angular, and some are older server-rendered pages. We also ship iOS and Android apps, and we run two brands plus a white-label version for partners. Designers work in Figma.

Real-life examples: Shopify Polaris, GitHub Primer, Atlassian Design System, Adobe Spectrum, IBM Carbon, Salesforce Lightning Design System

Requirements: what a design system for many teams has to do

A design system is the shared set of design decisions (colors, spacing, type), the components built from them (button, dialog, table), and the rules for using both. The design-systems lesson covers what each piece is. This guide is about the harder question: how you design one that 24 teams, 70 repos, several frameworks and two brands can actually use, and how you move all of them forward when it changes.

Interviewers like this question because it has almost no algorithm in it. It tests whether you think about the system as a product whose users are other engineers and designers.

A library that is technically great but that nobody upgrades is a failure. So is one that ships a breaking change on a Friday and breaks 40 checkout pages. Most of a strong answer is about those people problems, solved with engineering.

Questions to ask

The middle column is what this guide assumes. The right column is why the answer changes the design.

QuestionAnswer we assumeWhat it changes
Who uses it?24 product teams, about 400 frontend engineers, 60 designers.A self-serve product: docs, codemods and office hours, not a library one team can hand-hold.
Which frameworks?About 55 React repos (Next.js and Vite), 9 Angular repos, 6 server-rendered apps (Rails and Django templates), plus iOS and Android.Tokens must be framework-free. Components need a plan for React and for everyone else.
How many brands and themes?Two brands and one white-label partner theme; each in light, dark and high contrast.3 brands times 3 modes is 9 token sets. Theming must be data, not forked code.
Does a design system exist today?No. There are about 6 internal kits, 140 one-off button styles, and 900 distinct hex colors in the codebase.Adoption and migration are first-class problems, not an afterthought.
Who builds it?A core team of 7 engineers and 3 designers.7 people can't build 200 components for 24 teams. We need a contribution model.
Monorepo or many repos?Many repos; a few teams share a monorepo.Releases go out through npm, and every repo upgrades on its own schedule. Versioning and codemods matter.
What about server components?Two Next.js apps use the App Router and React Server Components.Components must work across the server and client boundary.

Functional requirements

  • Tokens: one source of truth for color, spacing, type, radius, shadow and motion, published as CSS variables, JavaScript constants, iOS Swift and Android XML from the same files.
  • Themes and brands: any app can switch brand or mode (light, dark, high contrast) without changing component code.
  • Components: about 60 core components at launch (button, text field, select, checkbox, dialog, tabs, toast, table, date picker) with keyboard and screen reader support built in.
  • Frameworks: first-class React; usable from Angular and server-rendered HTML.
  • Releases: versioned packages with changelogs, a clear deprecation policy and codemods for breaking changes.
  • Docs: a site with live examples, usage rules, do and don't pairs, generated props tables and links to Figma.
  • Contribution and measurement: teams can propose and contribute components; the core team can measure adoption per repo.

Non-functional requirements

RequirementTargetWhy this number
AccessibilityWCAG 2.2 level AA for every component, in every theme.A fix in one component then fixes it in every app that uses it.
Bundle costA button adds under 3 KB of JavaScript (gzipped, beyond React and the shared behavior core); the base CSS stays under 30 KB (gzipped).Teams compare the system against hand-written code. If it's heavier, they won't adopt it.
Runtime costNo extra style work in the browser at render time.Runtime CSS-in-JS adds work on every render; that hurts Interaction to Next Paint (INP).
Release speedA patch release reaches npm within 1 hour of merge.A product team blocked by a bug can't wait a sprint.
Upgrade health80% of repos on the latest major within 90 days; the previous major (N-1) gets fixes for 12 months.Two live majors is the most a team of 7 can support.
SafetyZero unreviewed visual changes in a release.A 1 px shift in a shared button changes thousands of screens at once.

Out of scope

  • Page templates and full product flows: the system ships building blocks and patterns, product teams build pages.
  • The internals of single components like a dialog, tabs or a select: the modal-dialog, tabs and dropdown-select guides cover those.
  • Dark mode switching on one site (the no-flash script, the three-state choice): the dark-mode-theming guide covers that. Here we care about how themes are packaged for many apps.
  • Sharing one runtime copy across micro-frontends: the micro-frontend-shell guide covers that. We only make sure the packages allow it.

Check your understanding · Exercise 1

Which answers to the scope questions change the design the most? Select all that apply.

What makes this hard: one system, many consumers, no stop button

A component library for one app is a folder. A design system for 24 teams is a platform with its own users, releases and support load. Five traits drive every decision in the rest of this guide.

  • Many consumers, one source. A change to a token or a component lands in 70 repos. The upside: fix a focus bug once, and 70 apps get the fix. The downside: break a button once, and 70 apps break. Every change has a blast radius, and we have to know how big it is before we ship.
  • Many platforms. React, Angular, plain HTML from Rails, Swift and Kotlin can't share component code. They can share decisions: the brand blue, the 8 px spacing step, the 200 ms motion. So the lowest layer must be data, not code.
  • Independent upgrade schedules. We can't upgrade other teams' apps for them. A team in the middle of a launch will stay on version 4 for months. So at any time several versions are live, and our versioning, deprecation and codemod story decides how painful that is.
  • Optional adoption. Teams can (and do) keep writing their own CSS. The system competes with copy and paste. If it's slower to use, heavier, or blocks a team for two weeks waiting for a variant, it loses. Developer experience is a feature.
  • Quality multiplies. A contrast bug in a one-off page affects one page. In a shared Text component it affects every screen in the company. Quality gates must be stricter than in any single app, and they must be automated, because nobody can click through 70 apps.

Those traits point to the layer stack from the design-systems lesson, with one change: components split into a behavior layer and a looks layer. The bottom layer is the most shared and the most stable: tokens, as plain data. Above it sit behavior primitives (keyboard, focus, ARIA) with no looks, then the styled components teams import.

On top sit patterns and product code. Each layer changes faster than the one below it, and each upper layer can be swapped without touching the lower ones.

The layers of the design system as a stack of five boxes. From the top: product apps, owned by 24 product teams in 70 repos; patterns such as forms and empty states, owned by the core team and contributors; styled components in the ds-react and ds-css packages, owned by the core team; headless behavior for keyboard, focus and ARIA, built on React Aria; and tokens at the bottom, plain DTCG JSON with no code, owned by the core team and designers. Each layer depends only on the layers below it. An arrow on the left says upper layers change faster and lower layers change slower.fasterslowerProduct apps24 teams, 70 reposproduct teamsPatternsforms, empty states, tablescore + teamsStyled componentsds-react, ds-csscore teamHeadless behaviorkeyboard, focus, ARIAReact AriaTokensDTCG JSON, no codecore + design
Each layer depends only on the layers below it, and changes faster than they do. Tokens are pure data, so every platform can use them; behavior has no looks, so it can be reused with any style.

Keep this picture in mind. Most mistakes in real design systems come from mixing layers: brand colors hard-coded inside a component (so a second brand needs a fork), keyboard logic tangled with styling (so the Angular team can't reuse it), or product-specific variants added to core components (so the button ends up with 23 props). The rest of the guide walks the layers bottom up, then covers how the whole thing ships, gets tested, documented, adopted and measured.

Think first

A product team asks you to make the default button 2 px taller because it looks small on their dashboard. What do you ask before saying yes?

Tokens and the token pipeline: one source for every platform and brand

A design token is one named design decision stored as data: color.action.primary is #0b5cd6, space.3 is 12px. The design-systems lesson covers why tokens exist, the three tiers and how to name them. The question here is where tokens live, what format they're in, and how one change reaches CSS, JavaScript, Swift and Kotlin in nine themes without anyone copying a hex code by hand.

The naive approach is a colors.ts file in the React package. It works for one React app. Then the Angular team copies it into a SCSS file, and the iOS team types the values into an asset catalog.

Six months later the brand blue changes from #0b5cd6 to #0a56c8. Someone updates the TypeScript file, and the other three copies drift. That is how a codebase ends up with 900 distinct hex values.

Decision Where should the source of truth for tokens live?

Figma is the source

Designers edit Figma variables. A script reads them through the Figma API and generates code.

Pros

  • Designers own the values and see them right away in their files.
  • No second tool to learn.

Cons

  • Figma has no pull requests, code review or semantic versioning for variable changes; a mistaken click ships.
  • Reading variables through the REST API needs an Enterprise plan.
  • Code-only tokens (z-index, breakpoints, easing curves) don't fit well.

Token files in git (DTCG JSON)

Tokens are JSON files in the design system repo, in the W3C Design Tokens format. A build step turns them into every platform's output. Figma variables are synced from and to these files.

Pros

  • Every change is a reviewed pull request with a diff, a changelog entry and a version.
  • Tool-agnostic: any tool that reads the standard can consume it.
  • CI can check contrast and naming on every change.

Cons

  • Designers need a sync step (a plugin or a bot) instead of editing code by hand.
  • Two-way sync needs a clear rule for conflicts.

A hosted token platform

A vendor tool stores tokens, offers a UI for designers and exports to code.

Pros

  • Nice editing UI and built-in Figma sync.

Cons

  • Another vendor and bill; the export still has to land in git to be versioned.
  • Lock-in if the export format is not the open standard.

What we pick: Token files in git, in the DTCG format, are the source of truth. That gives every token change the same review, versioning and changelog as code, and keeps us free to swap tools. Designers still work in Figma: a sync job turns their variable changes into a pull request, and merged token files are pushed back into the Figma library, so git always wins a conflict.

The format: W3C Design Tokens (DTCG)

The design-systems lesson introduces the JSON format from the W3C Design Tokens Community Group (DTCG). Its first stable version, 2025.10, came out in October 2025 as three modules: the Format (tokens, groups, aliases), Color (colors as objects with an explicit color space), and the Resolver (how to combine token sets for themes and brands). At our scale, the color format and the Resolver are the parts that matter.

json
{
  "color": {
    "blue": {
      "600": {
        "$type": "color",
        "$value": { "colorSpace": "srgb", "components": [0.043, 0.361, 0.839], "hex": "#0b5cd6" }
      }
    }
  }
}

{
  "color": {
    "action": {
      "primary": {
        "$type": "color",
        "$value": "{color.blue.600}",
        "$description": "Fill of the main call to action. One per view."
      }
    }
  }
}
tokens/primitive/color.tokens.json and tokens/semantic/color.tokens.json (shortened)

Note the color value. In the stable spec a color is an object with a colorSpace, a components array (0 to 1 for sRGB), an optional alpha and an optional hex fallback, not a bare hex string. That lets the same file carry wide-gamut display-p3 or oklch colors. Many tools written against older drafts still expect hex strings, so check what each tool in the pipeline reads.

Three tiers, enforced by tools

The three tiers (primitive, semantic, component) come from the design-systems lesson. With 24 teams they stop being a naming habit and become access rules: each tier may only point at the one below it, and each has a fixed audience.

TierExampleWho uses itChanges when
Primitive (the palette)color.blue.600, space.4 = 16pxOnly other tokens. Never components or product code.The brand palette changes. Rare.
Semantic (the meaning)color.action.primary, color.text.muted, space.inset.mdComponents and product code.A theme or brand maps meaning to different primitives.
Component (the exception)button.primary.bg, dialog.radiusOne component.A brand needs this one component to differ from the semantic default.

The semantic tier is the contract that product code and components depend on, and the only tier that themes and brands remap. Component tokens are kept few on purpose. Every one of them is a public knob that makes the system harder to change, so we add one only when a brand truly needs a single component to break from the semantic rule.

The rules only hold if tools check them. The token build fails when a component token points at a primitive. A Stylelint rule shipped to product teams rejects primitive variables such as --ds-color-blue-600 outside the token package, and the adoption scanner counts any that slip through.

Three columns of tokens. Primitive on the left: blue.600, green.600, gray.900 and gray.50. Semantic in the middle: action.primary and text.default. Component on the right: button.primary.bg. Arrows show the aliases for the main brand in light mode: button.primary.bg points to action.primary, action.primary points to blue.600, and text.default points to gray.900. A note at the bottom says the second brand's set makes action.primary point to green.600, and the dark mode set makes text.default point to gray.50; no component changes.primitivesemanticcomponentblue.600green.600gray.900gray.50action.primarytext.defaultbutton.primary.bgsecond brand: action.primary points to green.600dark mode: text.default points to gray.50
Components read the middle column only. A brand or mode is a small set that re-points a few semantic aliases, such as action.primary to green.600, so every component follows without a code change.

Check your understanding · Exercise 2

A product team writes background: var(--ds-color-blue-600) for their primary button. What goes wrong? Select all that apply.

The token build: one source, four platforms

A token build reads the JSON, resolves aliases, converts values to each platform's units and syntax, and writes files: CSS custom properties for the web, an ES module for JavaScript (charts and canvas need raw values), Swift for iOS, XML resources for Android. We use Style Dictionary, the open-source build tool the lesson introduces, with one config for every platform.

One caveat as of 2026: Style Dictionary 4 and later read the DTCG $value and $type keys, but its own docs say full support for the 2025.10 format is still work in progress in version 5. So we register a small custom transform that turns the color objects into CSS, Swift and Android values, and test it like any other code.

js
export default {
  source: ["tokens/primitive/**/*.json", "tokens/semantic/**/*.json", "tokens/brands/acme/light.json"],
  platforms: {
    css: {
      transformGroup: "css",
      prefix: "ds",
      buildPath: "dist/css/",
      files: [{ destination: "acme-light.css", format: "css/variables",
        options: { selector: '[data-brand="acme"][data-mode="light"]', outputReferences: true } }],
    },
    js: { transformGroup: "js", buildPath: "dist/js/",
      files: [{ destination: "acme-light.js", format: "javascript/es6" }] },
    ios: { transformGroup: "ios-swift", buildPath: "dist/ios/",
      files: [{ destination: "AcmeLight.swift", format: "ios-swift/class.swift", options: { className: "AcmeLight" } }] },
    android: { transformGroup: "android", buildPath: "dist/android/",
      files: [{ destination: "acme_light_colors.xml", format: "android/colors" }] },
  },
};
style-dictionary.config.js: one source, four platforms (one brand and mode shown)

The CSS output for one theme is a block of variables under a selector. outputReferences keeps the alias, so you can see in devtools that --ds-color-action-primary comes from --ds-color-blue-600. Components only ever read semantic variables, so switching brand or mode is one attribute change on the html element, with no JavaScript re-render and no new component code.

css
[data-brand="acme"][data-mode="light"] {
  --ds-color-blue-600: #0b5cd6;
  --ds-color-gray-900: #1a1d23;
  --ds-color-action-primary: var(--ds-color-blue-600);
  --ds-color-text-default: var(--ds-color-gray-900);
  --ds-color-text-on-action: #ffffff;
  --ds-space-inset-sm: 0.5rem;
  --ds-space-inset-md: 1rem;
  --ds-radius-control: 0.25rem;
}
dist/css/acme-light.css (generated, shortened)
The token pipeline as a data flow. On the left, Figma variables, edited by designers, and the token JSON files in git in the DTCG format. A sync bot turns Figma changes into a pull request into git, and pushes merged tokens back to Figma. From git, the nine brand and mode sets flow into Style Dictionary, which resolves aliases and transforms values. Style Dictionary writes five outputs: CSS variables, an ES module, Swift colors for iOS, Android XML resources, and a Tailwind theme.Figma variablesdesigners editPRon mergetokensDTCG JSON in git9 brand and mode setsStyle Dictionaryresolve, transformCSS variablesES moduleSwift colorsAndroid XMLTailwind theme
Git is the source of truth; Figma is kept in sync in both directions, and git wins a conflict. One build turns the same files into every platform's format, so a value is typed exactly once.

Brands and modes are token sets, not code

Three brands times three modes is nine combinations. Writing nine full token files by hand would be a maintenance trap: most values are the same everywhere.

Instead each combination is a stack of small token sets applied in order: the shared primitives, the shared semantic defaults, a brand set that overrides a few dozen tokens (brand colors, font family, corner radius), and a mode set that overrides colors for dark or high contrast. The DTCG Resolver module standardizes this: named sets, modifiers such as brand and mode with one context per value, and a resolutionOrder that decides which value wins.

In our numbers: about 180 primitives, 260 semantic tokens and 40 component tokens. A brand set overrides about 45 tokens; a mode set overrides about 70. The build writes nine CSS files of roughly 20 KB each before compression and 3 to 4 KB after gzip. An app loads only its own brand, so the token cost on the wire is a few kilobytes per mode it supports.

Native apps get the same decisions in their own idiom. On Android the mode sets go into values and values-night resource folders; on iOS a small custom format writes asset catalog colors with light and dark appearances, since the built-in Swift formats write plain constants. The point is not identical code on every platform but identical decisions: the brand green is the same number everywhere because it was typed once.

Primitive
Value
Brand
Mode
Token pipeline for acme-light. The primitive blue.600 is #0a56c8. Semantic tokens that point at it in this theme: action.primary, focus.ring. Components that change: Button, Link, TextField, Checkbox, Tabs. Themes where the change is visible: Acme light, Acme high contrast, Nova light, Partner light, Partner high contrast.primitivesemanticcomponentsblue.600#0a56c8action.primaryfocus.ringtext.defaultborder.strongradius.controlButtonLinkTextTextFieldCheckboxTabsvisible in 5 of 9 themes
Change a primitive, then switch brand and mode. The CSS file changes one line because semantic variables keep their var() reference; native files resolve every alias, so each semantic token that points at the primitive gets a new value.

acme-light.css: --ds-color-blue-600: #0a56c8;

Swift: public static let colorActionPrimary = UIColor(red: 0.039, green: 0.337, blue: 0.784, alpha: 1)

Android: <color name="color_action_primary">#ff0a56c8</color>

blue.600 is now #0a56c8. In Acme light, 2 semantic tokens and 5 components change. Visible in 5 of 9 themes.

Keeping Figma variables in sync

Figma variables are Figma's own token feature: values grouped into collections, each with modes (for example Light, Dark and High contrast). If the Figma library and the code disagree, designers hand off mockups in colors that don't exist, and engineers either guess or add a one-off hex. So the sync must be automatic in both directions, with git as the referee.

  1. Code to Figma. When token files merge, a job writes the values into the Figma library (through the Variables REST API, which needs an Enterprise plan, or a plugin such as Tokens Studio). Designers get a library update to accept.
  2. Figma to code. When a designer changes a variable, the plugin or a bot exports the change as DTCG JSON and opens a pull request. CI runs contrast checks and shows a visual diff; a design system engineer reviews and merges.
  3. Conflicts. If both sides changed the same token, the pull request shows it and a human decides. Figma never writes to the main branch directly.

Check your understanding · Exercise 3

The partner brand wants rounder corners on every component. Where should that change go? Select all that apply.

Components: behavior, styling and many frameworks

Components decide how things behave: a select must open with Enter, Space and the arrow keys, move focus through options, announce the selected value to a screen reader, close on Escape and return focus to its trigger. That behavior is the hardest part to get right and the part teams most often get wrong, so it's where a shared system saves the most work.

This chapter makes three decisions: how components are structured, how their styles are written and shipped, and how they reach apps that aren't React. The component-api-design lesson covers props, slots and naming in general; here we decide how to structure 60 components for 24 teams.

The naive approach is one styled component per widget, with behavior and looks mixed, and a prop for every request. After two years the Button has variant, size, color, fullWidth, iconLeft, iconRight, loading, loadingText, href, target, tooltip, badge and 11 more. Each prop interacts with the others, the test matrix explodes, and the Angular team still can't reuse any of the keyboard logic.

Decision How should components be structured inside the system?

Styled monoliths

Each component owns its markup, styles and behavior in one file, configured by props.

Pros

  • Fastest to start; one import per widget.
  • Easy for product engineers to use.

Cons

  • Behavior can't be reused with a different look (a second brand with different markup, or a one-off).
  • Prop explosion: every new need becomes a prop.
  • Accessibility logic is copied between similar widgets (menu, select, combobox).

Headless primitives plus a styled layer

A behavior layer (hooks or unstyled components) handles state, keyboard, focus and ARIA. A thin styled layer adds tokens, markup details and variants. Teams import the styled layer; power users can drop to the primitives.

Pros

  • The hard accessibility work is written and tested once.
  • A team with truly custom visuals reuses behavior instead of rewriting it.
  • Styled components stay small: mostly class names and slots.

Cons

  • Two layers to version and document.
  • More concepts for contributors.

Wrap a third-party styled library

Adopt a styled kit (for example MUI) and theme it to the brand.

Pros

  • Hundreds of components on day one.

Cons

  • Its look, its DOM and its upgrade schedule become ours.
  • Theming deep enough for three brands fights the library's own design language.
  • Bundle and runtime styling costs we can't control.

What we pick: Headless primitives plus a styled layer, built on React Aria. Radix Primitives, Base UI and Ark UI are good headless libraries too, but React Aria (Adobe's hooks and unstyled components behind React Spectrum) covers the widgets we most need help with, including combobox, date picker and table, which Radix Primitives doesn't offer. Adobe tests it across browsers, devices and screen readers. Our styled layer wraps it with tokens and our API, which keeps the 7-person team's effort on what's unique to us.

Two zones side by side. The headless layer, from React Aria, holds select state, keyboard and focus handling, and ARIA props. The styled layer, which the design system team writes, holds the ds-select classes, tokens and variants, and the compound Select API. The headless layer passes props and refs into the styled layer. Below, most teams import the styled Select, which comes from the styled layer, while a team with a truly custom look builds a custom select directly on the headless layer, with its own markup and the same tested behavior.headless layerselect statekeyboard, focusARIA propsstyled layer (ours)ds-select classestokens, variantsSelect.Item APIpropsrefsCustom selectown markupSelectmost teams
The hard, testable part (keyboard, focus, ARIA) is written once in the headless layer. Most teams use the styled Select; the few with custom visuals reuse the same behavior instead of writing a new listbox.

Composition over configuration

Instead of a prop for every part, complex components are built from named parts that share state through context. A team that needs a badge in the dialog header puts a badge in the header; nobody adds headerBadge to the API. This is how Radix, React Aria Components and most large systems shape their dialogs, menus and tabs.

tsx
<Dialog>
  <Dialog.Trigger asChild>
    <Button variant="secondary">Archive project</Button>
  </Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Title>Archive this project?</Dialog.Title>
    <Dialog.Description>Members lose access. You can restore it for 30 days.</Dialog.Description>
    <Dialog.Actions>
      <Dialog.Close asChild><Button variant="secondary">Cancel</Button></Dialog.Close>
      <Button variant="danger" onPress={archive}>Archive</Button>
    </Dialog.Actions>
  </Dialog.Content>
</Dialog>
Compound parts plus asChild: the trigger is the team's own Button, with the dialog's behavior merged in

Two patterns let one component render as a different element. A polymorphic as prop (Button as="a" href="/billing") swaps the tag; it's convenient but hard to type well in TypeScript, because the allowed props depend on the tag. asChild (Radix's name, also used by Ark UI) renders no element of its own and merges its props, handlers and ref into its single child. Base UI offers the same idea as a render prop.

We support asChild on trigger and item parts (Dialog.Trigger, Menu.Item), so a team's own button or router link can open a dialog, and we keep as off the public API. For links that look like buttons, the system's Link takes the button variants, and React Aria's RouterProvider lets it navigate with the app's own router, so the system never imports a router.

Controlled and uncontrolled, always both

Every stateful component (tabs, dialog, select, accordion) works uncontrolled by default (it keeps its own state, seeded by defaultValue) and controlled when the app passes value and onChange (the app owns the state, for example to sync with the URL). One small hook gives every component the same rule, so teams learn it once.

ts
export function useControllableState<T>(opts: {
  value?: T;
  defaultValue: T;
  onChange?: (next: T) => void;
}): [T, (next: T) => void] {
  const [inner, setInner] = useState(opts.defaultValue);
  const controlled = opts.value !== undefined;
  const current = controlled ? (opts.value as T) : inner;
  const set = useCallback((next: T) => {
    if (!controlled) setInner(next);
    opts.onChange?.(next);
  }, [controlled, opts.onChange]);
  return [current, set];
}

Check your understanding · Exercise 4

The search team needs a select that shows each option with a flag and a currency code, laid out in two columns. What is the system's best answer? Select all that apply.

Styling: static CSS, variables and layers

How the styles are written and delivered decides three things that teams feel every day: how much the system costs at runtime, whether it works with server rendering and React Server Components, and whether a product team can override a style without a fight over CSS specificity. It also decides whether the Angular and Rails apps can use the same styles at all.

Decision How should the system write and ship component styles?

Runtime CSS-in-JS (styled-components, Emotion)

Styles are JavaScript template literals or objects. At render time the library hashes them, injects style tags and returns class names.

Pros

  • Styles can read props directly.
  • Familiar to many React teams.

Cons

  • Work on every render: serializing and hashing styles costs main-thread time, which hurts INP on large pages. React's own docs advise against runtime style tag injection because it makes the browser recalculate styles far more often.
  • Works only in Client Components; server rendering needs a style registry that collects the styles of each request.
  • React only: the Angular and Rails apps get nothing.
  • styled-components entered maintenance mode in March 2025, and its maintainer advises against it for new projects.

Zero-runtime CSS-in-JS (vanilla-extract, Panda CSS, StyleX)

Styles are written in TypeScript but extracted to static CSS files at build time.

Pros

  • Type-safe styles and token references.
  • No style injection at runtime; works with server rendering and Server Components.

Cons

  • Needs a bundler plugin; a library must pre-build its CSS so consumers don't need the plugin.
  • Another tool for contributors to learn.

Plain CSS with custom properties and cascade layers

Each component has a CSS file using only semantic token variables. The build prefixes class names (ds-button) and publishes plain .css files, inside a named cascade layer.

Pros

  • Zero runtime; works in Server Components, SSR, Angular and server-rendered HTML alike.
  • Themes switch by changing variables, with no re-render.
  • The browser caches the CSS separately from JavaScript.

Cons

  • No type checking of class names or token names unless we add lint rules.
  • Variants are written as modifier classes or data attributes, by hand.

Utility classes with a Tailwind theme

The system ships a Tailwind theme generated from tokens (an @theme file in Tailwind v4, a preset in v3); components are built from utility classes.

Pros

  • Teams already on Tailwind feel at home; utilities stay on the token scale.

Cons

  • Components' class lists leak into every consumer's build; non-Tailwind apps can't use the styles.
  • Overriding a component means fighting long class strings.

What we pick: Plain CSS with token variables, shipped pre-built inside cascade layers. It is the only option that gives React, Angular and server-rendered pages the same styles with zero runtime cost, and it is safe for Server Components by default. We author with CSS Modules inside the system repo for local scoping, and the build turns them into stable ds- prefixed class names. For the many teams on Tailwind, we also generate a Tailwind theme from the same tokens so their own utilities stay on the scale.

Cascade layers: product CSS wins on purpose

The classic override problem: a product team writes .checkout .ds-button { padding: 0 } and it works, until the system ships a more specific selector and their fix silently stops working. Teams then reach for !important, and specificity wars begin.

Cascade layers (@layer, supported in every major browser since 2022) fix this structurally. For normal declarations, styles in a later layer beat styles in an earlier layer no matter how specific the selectors are, and styles in no layer at all beat every layer. (!important declarations reverse that order, which is one more reason to keep them out.)

css
/* @acme/ds-css/base.css */
@layer ds.reset, ds.tokens, ds.components, ds.utilities;

@layer ds.components {
  .ds-button {
    padding-block: var(--ds-space-inset-sm);
    padding-inline: var(--ds-space-inset-md);
    border-radius: var(--ds-radius-control);
    background: var(--ds-color-action-primary);
    color: var(--ds-color-text-on-action);
  }
  .ds-button[data-variant="secondary"] {
    background: transparent;
    color: var(--ds-color-action-primary);
  }
}

/* product app, unlayered: beats the system even with a weaker selector */
.ds-button { min-inline-size: 8rem; }
The system declares its layer order once; product CSS that isn't in a layer always wins
Cascade layer priority as a ladder, highest at the top: unlayered app CSS, then ds.utilities, ds.components, ds.tokens, and ds.reset at the bottom. An arrow on the left points up, from loses at the bottom to wins at the top. On the right, two notes compare a rule in ds.components, .ds-button:hover with specificity 0,2,0, which loses, against an unlayered app rule, .ds-button with specificity 0,1,0, which wins because layer order is checked before specificity.winslosesunlayered app CSSds.utilitiesds.componentsds.tokensds.resetin ds.components:.ds-button:hoverspecificity 0,2,0: losesunlayered app CSS:.ds-buttonspecificity 0,1,0: wins
The browser compares layers before specificity. So an app rule as plain as .ds-button beats the more specific system rule .ds-button:hover, and the system can refactor its selectors without breaking overrides.

Overrides are allowed but they are not free: they depend on our class names and DOM, which can change in a major version. So we offer escape hatches in order of safety.

First, props and variants. Second, component tokens set on a wrapper (--ds-button-radius: 0), which are a public, versioned API. Third, unlayered CSS on documented class names. The adoption scanner counts the third kind, because many overrides on one component signal that the component is missing something.

Check your understanding · Exercise 5

Why does runtime CSS-in-JS fit a company-wide design system poorly? Select all that apply.

Many frameworks: what to share and what to write twice

About 55 repos are React, 9 are Angular and 6 render HTML on the server with Rails or Django templates. Tokens and plain CSS already reach all of them. The question is behavior: should the Angular team get a working, accessible select, and if so, from what code?

Web components are the browser's own component model: custom elements (ds-select) with optional shadow DOM for encapsulated markup and styles. They run in any framework or none, which makes them look like the obvious answer. The web-components lesson covers how they work; here the question is whether they should be our main implementation.

Decision How should the system serve React, Angular and server-rendered apps?

Web components for everything

Every component is a custom element (often written with Lit). React and Angular use them directly or through thin wrappers.

Pros

  • One implementation for every web framework and plain HTML.
  • React 19 passes properties and listens to custom events on custom elements, which removed the worst interop pain.

Cons

  • Server rendering shadow DOM needs Declarative Shadow DOM and a framework-specific SSR step; React's server renderer won't render a Lit component's shadow content for you.
  • ARIA references like aria-labelledby can't point across a shadow root, which hurts labels, comboboxes and error messages.
  • Page CSS and cascade layers don't reach inside shadow DOM; overrides need ::part or variables.
  • React-specific patterns (asChild, render props, Server Components) don't map.

A separate package per framework

@acme/ds-react and @acme/ds-angular, each written natively, sharing tokens and CSS.

Pros

  • Each framework gets idiomatic APIs, SSR and tooling.

Cons

  • Behavior is written twice; bugs and fixes drift.
  • Angular's 9 repos would cost nearly as much as React's 55.

Shared tokens and CSS, React first, web components for leaf widgets

Everyone gets tokens and the CSS package. React gets the full component package. Simple, self-contained leaves (icon, spinner, badge, avatar, tooltip trigger) also ship as light-DOM custom elements for Angular and server-rendered pages. Complex widgets in Angular are built with the Angular CDK and Angular Aria (headless directives, in developer preview since Angular 21), styled with our CSS.

Pros

  • Effort matches usage: 80% of repos are React.
  • Leaves have no cross-root ARIA problems and render as plain HTML on the server.
  • Angular teams still get identical looks.

Cons

  • Angular's complex widgets are the Angular team's work, with our review and tests.
  • Two behavior implementations for a few widgets.

What we pick: Shared tokens and CSS for everyone, React first, and web components only for leaf widgets. Building every component as a web component would trade React's server rendering, Server Components and ARIA references for reach into 15 repos. The other choice is right in a different setting: Shopify moved Polaris to web components in 2025 because its consumers are third-party apps built with any framework. Our apps are internal and 80% React, so for a team of 7 the hybrid gives every app the same look and duplicates behavior only where usage justifies it.

How the packages reach each kind of app. Top row: ds-tokens in the middle feeds ds-css on the left, for every web app, and native tokens on the right, for iOS and Android. Middle row: ds-react with full components, ds-elements with light-DOM web components for leaf widgets, and Angular widgets on the CDK and Angular Aria, built by the Angular team. Bottom row: 55 React repos use ds-react; 6 Rails and Django apps use ds-elements and the CSS; 9 Angular repos use Angular CDK and Angular Aria widgets built by the Angular team, and also the ds-elements leaves. All web apps share the same CSS.ds-cssevery web appds-tokensevery platformnative tokensiOS, Androidds-reactfull componentsds-elementsleaf widgetsAngular widgetsCDK, Angular AriaReact: 55Rails, Django: 6Angular: 9
Everyone shares tokens, and every web app shares the same CSS, so all apps look the same. Behavior is written in full for React, where 80% of the repos are, and in light-DOM web components only for simple leaf widgets.

For the server-rendered apps, the CSS package documents the HTML each component needs (button class="ds-button" data-variant="secondary"). Those class names and attributes become public API under semver, the same as React props. A Rails partial or Django template tag wraps the markup so templates don't repeat it.

GitHub's Primer ran this split for years: Primer React, Primer ViewComponents for Rails, and shared CSS and tokens. In 2026 GitHub put the Rails library into maintenance mode to focus on React. That is the cost of a second implementation made visible, and it's why ours stays limited to markup and a few leaf elements.

Think first

Why build the leaf web components with light DOM (no shadow root) instead of shadow DOM?

Check your understanding · Exercise 6

Which problems would a shadow-DOM ds-combobox web component cause in our React apps? Select all that apply.

Shipping change: packages, versions and codemods

The system is published to the company's private npm registry, and every repo upgrades on its own schedule. This chapter covers the three things that decide how painful that is: how the system is split into packages, what counts as a breaking change, and how breaking changes reach 70 repos. The monorepos lesson covers the repo side.

Start with the packages. How the system is split decides how teams install it, how versions can drift apart, and how much code ends up in each app.

Decision Should the system ship as one package or one package per component?

One big package

@acme/ds-react contains every component; one version number for all.

Pros

  • One version to reason about: all components are known to work together.
  • One upgrade per team; simple docs (Primer React and MUI ship this way).

Cons

  • Any breaking change in any component forces a major for everything.
  • Teams that only use Button still upgrade for a Table change.

One package per component

@acme/ds-button, @acme/ds-dialog, each versioned on its own (Atlassian's @atlaskit/* packages work this way).

Pros

  • A breaking change in Dialog doesn't touch Button users.
  • Teams upgrade piece by piece.

Cons

  • A version matrix: Dialog 6 depends on Button 4 while the app uses Button 5, so two Buttons ship.
  • 60 changelogs, 60 install lines, many duplicate copies.
  • Hard to say which combinations were tested together.

A few layered packages, one version per layer

@acme/ds-tokens, @acme/ds-css, @acme/ds-react, @acme/ds-icons, @acme/ds-elements. Each component is a subpath export (@acme/ds-react/button) so bundlers only include what's imported.

Pros

  • Components inside a package are always tested together.
  • Tokens and CSS version separately, so non-React apps aren't bumped by React changes.
  • Subpath exports and tree shaking keep bundles small.

Cons

  • A breaking change in one component still means a major for the React package; codemods must make that cheap.

What we pick: A few layered packages, one version per layer. Components that render each other (dialogs contain buttons, tables contain checkboxes) need to be tested and released together, and per-component versions turn that into a matrix nobody can test. We control the cost of majors with a strict deprecation policy and codemods, which the rest of this chapter covers.

json
{
  "name": "@acme/ds-react",
  "version": "5.3.0",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button/index.js",
    "./dialog": "./dist/dialog/index.js",
    "./styles.css": "./dist/styles.css"
  },
  "sideEffects": ["**/*.css"],
  "peerDependencies": {
    "react": "^18.3.0 || ^19.0.0",
    "react-dom": "^18.3.0 || ^19.0.0",
    "@acme/ds-css": "^5.0.0"
  }
}
packages/react/package.json: subpath exports, side-effect rules, and peers

Avoiding two copies in one app

Duplicate copies are the most common packaging bug in large companies. The app installs @acme/ds-react@5, but an internal package (say, the shared checkout widget) depends on @acme/ds-react@4, so npm installs both. The app now ships two Buttons (tens of kilobytes more JavaScript) and two React contexts, so the checkout widget doesn't see the app's theme or locale provider. It also gets two focus managers: a dialog from one copy doesn't know about a dialog from the other, so Escape can close the wrong one.

  • Libraries use peer dependencies. Any internal package built on the system lists it in peerDependencies with a range (^4.0.0 || ^5.0.0), never in dependencies. The app decides the one copy.
  • React is always a peer, for the same reason: two React copies break hooks entirely.
  • CI checks for duplicates. A step runs npm ls @acme/ds-react (or pnpm why) and fails the build if more than one version resolves.
  • Micro-frontends share one copy at runtime through the shell's module sharing; the micro-frontend-shell guide covers that setup.

Check your understanding · Exercise 7

An app's bundle report shows two copies of @acme/ds-react. What should the team do? Select all that apply.

Versioning: what counts as breaking for a component

The design-systems lesson explains semantic versioning (semver) for UI: major for breaking changes, minor for additions, patch for fixes, and a public surface much wider than props. With 70 repos, a rough rule is not enough. A change that looks small to us can fail another team's tests, layout or screen reader flow, so the line has to be written down and applied the same way by every contributor.

So the system writes down, in its docs, what the public API of a component is. Ours is: the props and their types and defaults, the documented class names and data attributes of the CSS package, the semantic and component tokens, the accessible role and name of each part, and the keyboard behavior. The exact DOM inside a component is not public unless documented, and we say so in the docs, so teams know that .ds-dialog > div > div selectors are on their own.

ChangeBumpWhy
Rename or remove a propMajor (after a deprecation period)Code that passes the old prop fails to type-check or silently loses behavior.
Change a default valueMajorEvery call site that relied on the old default changes without a code change.
New optional prop or new variantMinorOld code keeps working; new code can opt in.
Wrap internals in an extra elementMinor, or major if a documented class or slot movesUndocumented DOM is not public, but documented parts are.
Remove or rename a documented class name or tokenMajorServer-rendered apps and product CSS reference these directly.
Fix a missing accessible name or wrong rolePatch, flagged in the changelogThe old behavior was a bug. Holding a fix for a major leaves users stuck; tests that query by role may need an update.
Visual change: a new color or spacing valueMinor, reviewed in visual diffsNot breaking for code, but it changes every screen, so it gets a changelog entry and screenshots.
Raise the minimum React versionMajorApps on the old React can no longer install it.
Changes
The next release of ds-react, from 5.3.0. Changes picked: Add tone=critical, a minor, 0 repos affected; Deprecate danger, a minor, 38 repos affected; Fix aria-selected, a patch, 12 repos affected. The release becomes 5.4.0.changebumprepos touchedAdd tone=criticalminor0 of 70Deprecate dangerminor38 of 70Remove danger--Dialog extra div--Rename tabs class--Fix aria-selectedpatch12 of 70Default size to sm--ds-react 5.3.0 becomes 5.4.0codemods: button-tone
The biggest bump wins: one breaking change makes the whole release a major, however small the rest is. Each row also says which codemod ships with it and how many of the 70 repos the scanner says are touched.

Add tone=critical: minor. Codemod: none. A new, optional prop. Old code keeps working.

Deprecate danger: minor. Codemod: button-tone (optional). The old prop still works and warns in development. Teams can migrate early.

Fix aria-selected: patch. Codemod: none. An accessibility bug fix ships as a patch. 12 repos have tests that query by role and may need an update.

No breaking changes: teams get this through a normal upgrade pull request.

Changesets: every pull request says what it changes

Changesets, introduced in the design-systems lesson, records the bump inside each pull request: a small markdown file naming the packages touched, the bump type and a changelog line. Two rules make it reliable with many contributors. CI fails a pull request that changes package code without a changeset, and a reviewer checks the bump against the table above. At release time the tool takes the biggest bump per package, writes each CHANGELOG.md and publishes.

text
---
"@acme/ds-react": minor
"@acme/ds-css": minor
---

Button: add the `tone="critical"` variant. Deprecate `variant="danger"`;
it keeps working until 6.0. Run `npx @acme/ds-codemods button-tone` to migrate.
.changeset/brave-owls-sing.md, written by the contributor

Deprecation policy and N-1 support

Breaking changes never arrive by surprise. Every component and every prop moves through the same lifecycle, and each step is visible to teams in their editor, in the console and in the docs.

A state machine of a component's lifecycle. Proposed moves to experimental when its RFC is accepted. Experimental moves to beta once two teams use it. Beta can go back to experimental if the API changes, and moves to stable after accessibility and screen reader review. Stable moves to deprecated in a minor release that names the replacement and ships a codemod. Deprecated moves to removed in the next major, at least three months later. A note says stable components get fixes in the current and previous major for 12 months.proposedRFC openexperimentalmay changebetaAPI settlingRFC ok2 teamsAPI changea11y reviewremovednext majordeprecatedstill worksstableN and N-1minor3+ monthsstable: fixes in N and N-1 for 12 months
Every step is visible: a status badge in the docs, a strike-through in the editor for deprecated APIs, a dev-only console warning, and a lint rule. Nothing is removed without passing through deprecated first.
  • Deprecate in a minor. The old prop or component keeps working. Its type gets a @deprecated JSDoc tag, so editors strike it through. In development builds it logs one console warning naming the replacement and the codemod. A lint rule flags it.
  • Remove in the next major, at least 3 months later. Majors ship at most twice a year, on a published calendar, so teams can plan.
  • Support N-1 for 12 months. The previous major gets security fixes, accessibility fixes and critical bugs, back-ported from main. Nothing older gets fixes. Two live majors is what 7 people can maintain.

Check your understanding · Exercise 8

Which of these changes to Tabs must wait for a major version? Select all that apply.

Migrations: codemods and upgrade pull requests

A deprecation policy tells teams what will break. It doesn't do the work. If renaming variant="danger" to tone="critical" means 70 repos each spend an afternoon finding and editing call sites, teams postpone the upgrade, N-1 support stretches into N-3, and the system's team spends its time back-porting. The fix is to make upgrades almost free: the system team ships the code change along with the breaking change.

A codemod is a program that rewrites source code. Instead of search and replace on text, it parses the code into a syntax tree, finds the exact pattern (a Button imported from our package, with a variant attribute equal to "danger"), and edits the tree. So it won't touch a different Button or a string in a comment.

jscodeshift (from Meta) is the long-standing tool for JavaScript and JSX transforms. ts-morph wraps the TypeScript compiler API and is better when the change needs type information, such as finding every place a prop's type is used.

js
export default function transform(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);
  const names = new Set();
  root.find(j.ImportDeclaration, { source: { value: "@acme/ds-react" } })
    .find(j.ImportSpecifier, { imported: { name: "Button" } })
    .forEach((path) => names.add(path.node.local.name));
  if (names.size === 0) return undefined; // file untouched

  root.find(j.JSXOpeningElement)
    .filter((path) => names.has(path.node.name.name))
    .find(j.JSXAttribute, { name: { name: "variant" } })
    .forEach((path) => {
      const value = path.node.value;
      const isString = value && (value.type === "StringLiteral" || value.type === "Literal");
      if (isString && value.value === "danger") {
        path.node.name.name = "tone";
        path.node.value = j.stringLiteral("critical");
      } else if (value && value.type === "JSXExpressionContainer") {
        path.node.comments = [j.commentBlock(" TODO(ds-6): if this can be danger, use tone ")];
      }
    });
  return root.toSource();
}

export const parser = "tsx"; // parse TypeScript and JSX
codemods/button-tone.js (jscodeshift): only Buttons imported from our package

Codemods are tested like any code: each one has fixture files (input and expected output) covering aliased imports, dynamic values and unrelated Button components from other libraries. A good codemod handles about 90% of call sites and marks the rest with a searchable comment, rather than guessing. Guessing wrong is worse than not changing the code, because the result type-checks and ships.

Running it across the fleet

Decision How do we get a breaking change into 70 repos?

Announce and wait

Publish the major, post release notes and a migration guide, and let teams upgrade when they can.

Pros

  • No access to other teams' repos needed.

Cons

  • Upgrades take quarters; many repos never move.
  • The system team supports many majors forever.

A migration bot that opens pull requests

A service clones each repo that uses the package, bumps the version, runs the codemods, runs the repo's own type check and tests, and opens a pull request with the diff, the changelog and any TODOs.

Pros

  • Owners review a ready pull request instead of doing the work.
  • The bot's dashboard shows exactly which repos are blocked and why.

Cons

  • Building and running the bot is real work.
  • Needs a repo inventory and write access to open pull requests.

A monorepo with one atomic change

Move every app into one repo; the system team changes every call site in the same commit.

Pros

  • No version skew at all.

Cons

  • Not our world: 70 repos across 24 teams. A company-wide monorepo move is a multi-year project of its own.

What we pick: A migration bot. Renovate already opens version-bump pull requests, and a self-hosted Renovate can run post-upgrade commands on its branch, so it runs the matching codemods for the versions crossed and commits the result to the same pull request. Owners get a green pull request in most repos. The bot's dashboard turns the 90-day upgrade target into a list we can chase.

A sequence diagram of a major release reaching product repos. Lifelines: registry, migration bot, repo CI, and repo owner. The registry tells the bot that version 6.0.0 is published. The bot pushes a branch to repo A with the version bump and codemods applied; repo A's CI reports that tests pass; the bot opens a pull request for the owner, who merges it. Failure path: for repo B, the bot pushes the bump, CI reports a type error from a dynamic prop the codemod could not change, and the bot opens a draft pull request listing the TODOs for the owner to fix by hand.RegistryMigration botRepo CIOwner6.0.0 publishedA: bump, codemodstests passopen PRmergefailure: repo BB: bump, codemodstype errordraft PR, TODOs
Most repos get a green pull request to review and merge. When the repo's own checks fail, the bot doesn't hide it: it opens a draft with the failing check and the codemod's TODO comments, so the owner knows exactly what's left.

Plan for a long tail. Our planning numbers for a major: about 60% of repos merge the bot's pull request within two weeks, another 25% need a small manual fix (a TODO the codemod left, or a snapshot test), and the last 15% are frozen, abandoned or mid-launch. The tail is where office hours and pairing go. The bot also refuses to open pull requests in repos with failing main branches, so it doesn't add noise to a broken build.

Check your understanding · Exercise 9

Why parse the code into a syntax tree instead of running a search and replace for variant="danger"? Select all that apply.

Quality gates: nothing reaches 70 repos unreviewed

A bug in a shared component is a bug in every app at once, and product teams will stop upgrading after the first bad release. So every pull request to the system passes automated gates that check what reviewers can't see by reading a diff: how each component looks in nine themes, whether it is still accessible, whether it still works with a keyboard, and whether it got heavier.

The testing-strategy and contract-and-visual-tests lessons cover these test types in general; here is how they fit a design system. The backbone is Storybook. Every component has stories: one per meaningful state (default, hover, focus, disabled, loading, error, long text, right to left).

Stories are the docs examples, and they are also the test cases: the same story is screenshotted, scanned by axe and driven by an interaction test. Writing a story is how a contributor adds test coverage.

GateToolCatchesBlocks merge?
Types and lintTypeScript, ESLint, StylelintBroken props, primitive tokens used in components, hard-coded colorsYes
Unit and interaction testsVitest, Testing Library, Storybook play functionsKeyboard behavior, focus return, controlled and uncontrolled stateYes
Visual regressionChromatic, or Playwright toHaveScreenshot on storiesAny pixel change in any story and themeUntil a human approves the diff
Automated accessibilityaxe-core on every story, in every modeMissing names, invalid ARIA, contrast failuresYes, for serious and critical rules
Bundle budgetsize-limit per subpath exportA component growing past its budget, such as Button over 3 KB gzippedYes, unless the budget is raised in review
Changeset presentChangesets botPackage changes without a version noteYes
Manual screen reader passNVDA, JAWS, VoiceOver, TalkBackAnnouncements and flows that automation can't judgeBefore a new component or a behavior change goes stable

Decision Which visual regression setup fits the system?

A hosted service (Chromatic)

Uploads the built Storybook; renders every story in cloud browsers; shows diffs for review on the pull request.

Pros

  • Made by the Storybook maintainers; review UI, baselines per branch and theme modes are built in.
  • Consistent rendering, no flaky font differences between machines.

Cons

  • Priced per snapshot, and each story in each browser and mode is one snapshot: 60 components times 8 stories times 9 themes is about 4,300 per run. TurboSnap, which re-tests only stories affected by a change, cuts that a lot.

Self-hosted Playwright screenshots

A Playwright test visits each story URL and calls toHaveScreenshot; baselines are committed images.

Pros

  • No per-snapshot bill; full control.

Cons

  • Baselines must be generated in the same Docker image as CI, or fonts and anti-aliasing differ.
  • We build the review workflow ourselves.

What we pick: Either works; the decision is cost against effort. We pick the hosted service for the review workflow and cut the snapshot count: every story in the default brand's light mode (480), plus a curated set of about 120 key stories in the other eight brand and mode combinations (960). That is about 1,440 snapshots for a full run instead of 4,300, and it still catches theme bugs such as a token missing in dark mode.

A sequence diagram of a pull request to the design system. Lifelines: contributor, CI, reviewer, and registry. The contributor pushes a pull request with a changeset. CI's axe check fails because an icon button is missing an accessible name, and reports it back. The contributor fixes it and pushes again. CI passes types, tests, axe and size, and sends 12 visual diffs to the reviewer, who approves them. CI merges and publishes version 5.4.0 to the registry, and the registry confirms 5.4.0 is live, with the changelog and docs deployed.ContributorCIReviewerRegistryPR + changesetaxe: no namefix, push12 visual diffsapprovepublish 5.4.05.4.0 live
The failing gate comes first: axe stops the pull request before a human spends time on it. Visual diffs are the only gate that needs a person, because only a person can say a pixel change is intended.

Automated accessibility is a floor, not a ceiling

axe-core finds problems that can be decided from the DOM: an image without alt text, a button with no accessible name, a contrast ratio of 3.9 to 1 where 4.5 to 1 is required. It can't tell whether the name is helpful, whether focus lands in a sensible place after a dialog closes, or whether a live region announces too much.

In a 2021 study of more than 2,000 of its own audits, Deque, which makes axe, found that automated tests caught about 57% of issues by volume. The most frequent issue types happen to be ones tools can detect, so the share is far lower when counted by WCAG success criteria. Even by the kinder count, more than 40% of issues need a human to find them.

So every new component, and every change to behavior, gets a manual pass with a screen reader matrix before it moves from beta to stable: NVDA with Firefox or Chrome, JAWS with Chrome, VoiceOver with Safari on macOS and iOS, and TalkBack with Chrome on Android.

Check your understanding · Exercise 10

A contributor changes a semantic token so dark-mode muted text gets lighter. Which gates should catch a problem, if there is one? Select all that apply.

Documentation: the system's real user interface

Engineers meet the system through its docs long before they read its code. If they can't find the right component in a minute, they write their own. So the docs are part of the product, with the same review and the same release as the code. A docs page that is out of date is worse than none, because it teaches the wrong thing with authority.

Decision Should Storybook be the docs site, or should we build a separate one?

Storybook only

Publish Storybook with autodocs pages and MDX for guidelines.

Pros

  • No second site; props tables and examples come for free.

Cons

  • A workbench for engineers, not a reference for designers and product managers.
  • Guidelines, tokens and patterns pages fit awkwardly.

A docs site built from the same stories

A static docs site (for example Next.js or Astro) imports the stories with composeStories and renders them as live examples, next to written guidelines and generated props tables. Storybook stays as the engineers' workbench and the test harness.

Pros

  • One source for examples: a story is a test, a Storybook entry and a docs example at once.
  • Room for guidelines, tokens, patterns, accessibility notes and Figma links, for designers too.

Cons

  • A second site to build and keep fast.

What we pick: A separate docs site built from the same stories. Polaris, Primer, Carbon and Atlassian all run a dedicated docs site for exactly this reason: the audience includes designers and product managers, and the content is mostly guidance, not props. Because examples come from stories, an example can't drift from the tested behavior.

Every component page has the same parts in the same order, so readers learn where to look. Props tables are generated from the TypeScript types at build time (react-docgen-typescript reads prop names, types, defaults and JSDoc comments), so a new prop appears in the docs the moment it ships. Hand-written props tables drift within weeks.

A wireframe of the docs page for the Dialog component. On the left, a navigation column with Foundations, Tokens, Components and Patterns. At the top of the main area, the title Dialog with a stable status badge and a Figma link. Below it, a live example rendered from the Storybook story, with chips to switch the brand and the mode. Then tabs for Usage, Accessibility and Props, with Usage selected. Under the tabs, a do example with one primary button, and a don't example with three. At the bottom, a props table generated from the TypeScript types.docs: DialogFoundationsTokensComponentsPatternsDialogstableFigma linklive example, from the storybrand AdarkUsageAccessibilityPropsdo: 1 primarydon't: 3props table, from the types
Every component page has the same parts in the same order. The example comes from the same story the tests run, and the props table comes from the types, so neither can drift from the code.
  • Status badge: experimental, beta, stable or deprecated, with the replacement linked.
  • Live examples from stories, with a copy button and a theme switcher for all nine brand and mode combinations.
  • When to use and when not to, with do and don't pairs: "Use one primary button per view" next to a screenshot with three.
  • Accessibility: the keyboard map, what the component announces, and what the product team must still do (for example, give an icon button a label).
  • Props table generated from types, and the component tokens it exposes.
  • Links: the Figma component, the source, the changelog filtered to this component, and the codemods that touch it.

Check your understanding · Exercise 11

Which docs practices keep the docs from drifting away from the code? Select all that apply.

Architecture, data model and interfaces

With the layers decided, here is the system as a set of parts with one job each. Most of it is not runtime code in the browser; it's the build, test, release and measurement machinery around the components. That is normal for a platform product: the components are only the visible part.

PartResponsibilityWhy it's its own part
Token source (DTCG JSON in git)Every design decision, in sets for brands and modes.The one place a value is typed. Everything else is generated.
Token build (Style Dictionary)Resolve aliases and sets; emit CSS, JS, Swift, Android XML, the Tailwind theme and Figma variables.Platforms change their formats; the source shouldn't care.
Packagesds-tokens, ds-css, ds-react, ds-elements, ds-icons, ds-codemods.Each layer versions at its own pace; non-React apps don't follow React majors.
Storybook and testsStories per state; interaction, visual, axe and size checks.One harness for tests, examples and review.
Release pipelineChangesets, publish to the private registry, deploy docs.Releases are a button, not a person's afternoon.
Docs siteGuidelines, live examples, generated props, Figma links, versioned per major.The main user interface of the system.
Figma sync botPushes merged tokens to the Figma library; opens pull requests for designer changes.Design and code must not drift.
Migration botOpens upgrade pull requests with codemods applied; tracks status per repo.Moves 70 repos forward without 70 afternoons of work.
Adoption scannerReads every repo and the Figma files weekly; stores usage, overrides and one-off values.Without it, adoption is a guess.
The architecture of the design system. On the left, the design system repo holds four parts top to bottom: tokens in DTCG JSON, Style Dictionary, components with their stories, and CI gates with the release step. The Figma library on the right syncs with the tokens in both directions. CI publishes to the npm registry, beside which sits the docs site. The registry feeds the migration bot, which opens upgrade pull requests in the 70 product repos at the bottom: React, Angular and Rails apps. The adoption scanner reads those repos.design system repotokens (DTCG)Style Dictionarycomponents, storiesCI gates, releaseFigma libraryregistrydocs sitemigrate botscanner70 product reposReact, Angular and Rails appsPRsreads
Everything above the product repos is the platform the 7-person team runs. The bots are outside the packages: they act on other teams' repos and must never be a dependency of an app.

The boundaries follow who changes what and how often. Tokens change when design decisions change, and every platform consumes them, so they are data at the bottom with no dependencies. CSS depends only on tokens, so Rails and Angular can take it without React. The React package depends on CSS and on the headless library, never the other way.

The bots and the scanner sit outside the packages. They read and write other teams' repos, so they need their own permissions, logs and rate limits, and they must never be a dependency of an app.

Follow one change through the system: a designer darkens the muted text color for dark mode. It starts in Figma, becomes a pull request, passes the contrast check and visual review, merges, gets released as a minor, reaches apps through the migration bot or the team's own upgrade, and is finally served to users as a few changed bytes of CSS.

The data flow of one token change, in three rows. Top row, left to right: a Figma variable change, which the sync bot sends as a token pull request with a JSON diff, which is pushed to CI checks for contrast and visuals. CI merges into a release, version 5.4.0 made with changesets, in the middle row on the right. The release is published to the registry, and the registry's new version reaches an app repo as a bump pull request. Bottom row: the app repo's build deploys a hashed CSS file to the CDN; the browser downloads the CSS file and re-resolves the variables; and the screens repaint with the lighter muted text.Figma variablemuted texttoken PRJSON diffCI checkscontrast, visualssync botpushrelease 5.4.0changesetsregistrynpm packageapp repobump PR mergedmergepublishbumpCDNhashed CSS filebrowservars re-resolvescreenslighter muted textbuild, deployCSSrepaint
What crosses each arrow changes shape: a variable, a JSON diff, a package, a version bump, a CSS file, and finally pixels. Only the last hop reaches users, and it costs a few changed bytes of CSS.

Check your understanding · Exercise 12

Why does @acme/ds-css not depend on @acme/ds-react, while the React package depends on the CSS package? Select all that apply.

Data model: tokens, components and adoption records

A design system has little user data, but it has three kinds of records that tools read and write: tokens (the design decisions), a component manifest (what exists, its status, owner and links), and adoption records (what each repo uses). Each lives somewhere different, chosen by who writes it and how it's versioned.

ts
// Lives in git as DTCG JSON; versioned with the packages.
type Token = {
  path: string[];              // ["color", "action", "primary"]
  $type: "color" | "dimension" | "fontFamily" | "duration" | "shadow";
  $value: unknown;             // a literal or an alias like "{color.blue.600}"
  tier: "primitive" | "semantic" | "component";
  $deprecated?: string;        // "Use color.text.muted"
};

// Generated at build time from source and JSDoc; published with the docs.
type ComponentManifest = {
  name: string;                // "Dialog"
  status: "experimental" | "beta" | "stable" | "deprecated";
  since: string;               // "3.2.0"
  owner: string;               // "core" or a contributing team
  figmaNodeUrl: string;
  tokens: string[];            // component tokens it reads
  sizeBudgetKb: number;        // gzipped
};

// Written weekly by the scanner; stored in a small database behind a dashboard.
type AdoptionRecord = {
  repo: string; team: string; dsVersion: string | null;
  componentUses: Record<string, number>;   // { Button: 412, Dialog: 38 }
  localLookalikes: number;                 // home-made buttons, modals...
  overrides: number;                       // unlayered CSS on ds- classes
  rawColorValues: number;                  // hex and rgb outside tokens
  scannedAt: string;
};

Tokens live in git because they need review and versions. The manifest is generated, never hand-edited, so the docs, the scanner and the lint rules all agree on which components exist and which are deprecated. Adoption records live in a database because they are measurements over time, not source; the dashboard needs trends per team, not the latest file.

The data model as three columns. Token, which lives in git, has path, type, value, tier and deprecated. The component manifest, generated JSON, has name, status, since, owner, Figma URL, tokens and size budget. The adoption record, stored in the scanner's database, has repo and team, design system version, component uses, lookalikes, overrides, raw colors and scanned at. Arrows show the manifest's tokens field pointing at token paths, and the manifest's component names feeding the adoption record's component uses.Tokenlives in gitpath$type$valuetier$deprecatedManifestgenerated JSONnamestatussinceownerfigma urltokenssize budgetAdoptionscanner databaserepo, teamdsVersioncomponentUseslookalikesoverridesrawColorsscannedAt
The manifest is the hub: lint rules, docs and the scanner all read it, so they agree on which components exist and which are deprecated. Only tokens are written by hand.

Interfaces: the contracts teams code against

A design system has four public interfaces: component props, CSS classes and data attributes, token names, and the tools (codemods, lint rules, CLI). Consistency across them matters more than any single API. If every component handles refs, class names, state and events the same way, a team that learned Button already knows half of Dialog.

tsx
type ButtonProps = {
  variant?: "primary" | "secondary" | "tertiary";  // default "secondary"
  tone?: "neutral" | "critical";                   // default "neutral"
  size?: "sm" | "md" | "lg";                       // default "md"
  isDisabled?: boolean;
  isPending?: boolean;          // shows a spinner, keeps focus, announces "Loading"
  onPress?: (e: PressEvent) => void;  // mouse, touch, keyboard, and screen reader
  children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<"button">, "onClick" | "disabled">;

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "secondary", tone = "neutral", size = "md", className, ...rest }, ref) {
  // behavior from the headless layer; looks from ds-css classes and data attributes
  return <AriaButton ref={ref} className={cx("ds-button", className)}
    data-variant={variant} data-tone={tone} data-size={size} {...rest} />;
});
The shape every component follows, shown on Button
  • Every component forwards its ref and spreads remaining props onto its root element, so id, aria-*, data-testid and event handlers just work.
  • State is exposed as data attributes (data-state="open", data-disabled), the same ones the CSS uses, so product CSS and tests have a stable hook that is part of the public API.
  • Booleans read as questions (isDisabled, isPending), variants are string unions with a documented default, and nothing takes a raw color.
  • Codemods have one CLI: npx @acme/ds-codemods list shows the transforms for a version range, and npx @acme/ds-codemods 6.0 src/ runs them all.
  • Lint rules ship as a package: @acme/eslint-plugin-ds and a Stylelint config flag deprecated props, primitive tokens and raw hex values in product code.

Note what the API leaves out: no color prop, no style passthrough for colors, no margin prop. Spacing between components belongs to layout components (Stack, Inline, Grid) that read the spacing scale. Every prop we don't add is one we never have to deprecate.

Adoption and governance: making teams want it

A design system nobody adopts is an expensive side project. Adoption is not won by a mandate from leadership; teams comply on paper and keep their own buttons. It's won by being the fastest way to ship a good screen, and by a governance model that lets teams get what they need without waiting on 7 people.

The design-systems lesson compares the three governance models in general. Here the question is concrete: 24 teams will ask for far more components and variants than 7 people can build, so who builds them?

Decision Who builds new components and variants?

Centralized: only the core team

Teams file requests; the core team designs, builds and ships everything.

Pros

  • Consistent quality and API style.
  • Clear ownership.

Cons

  • The core team becomes a queue. With 24 teams asking, waits run into months, and teams build their own in the meantime.

Federated: anyone builds, a council reviews

Product teams build components in the system repo; a group of representatives approves.

Pros

  • Scales with the company; the people with the need do the work.

Cons

  • Quality and consistency drift; nobody owns old components when the contributing team moves on.

Core team owns, teams contribute through a defined path

The core team owns quality, the API style and long-term maintenance. Teams propose with a short RFC, build with core pairing, and pass the same gates. Components start as experimental and graduate to stable.

Pros

  • Capacity grows with contributors while one team keeps the bar.
  • The experimental stage lets a team ship now without a lifetime promise.

Cons

  • The core team spends real time reviewing and pairing (about 30% of its capacity).

What we pick: The core team owns, and teams contribute through a defined path. Pure centralization makes the system a bottleneck and pushes teams back to copy and paste; pure federation produces six kits in one repo. A light RFC, pairing sessions and the experimental stage let contributions flow while one team stays accountable for quality.

  • RFCs: a one-page proposal (the problem, three teams that need it, the proposed API, accessibility notes). Reviewed weekly in public.
  • Office hours twice a week, and a support channel with a published response time. Questions that come up twice become docs.
  • Early adopters: start with two or three willing teams on real features, not a big-bang mandate. Their screens become the case studies that convince the rest.
  • Make the right thing the easy thing: project templates with the system preinstalled, lint rules with autofix, and Figma libraries enabled by default for every design file.

Measuring adoption

Download counts on the registry say almost nothing: one install can mean 400 uses or one. The scanner reads code instead. Each week it parses every repo (with the same syntax tree tools the codemods use) and counts imports of each system component, home-made lookalikes (a Button or Modal defined in the repo), unlayered overrides of ds- classes, and raw color values outside tokens.

You don't have to build all of it. react-scanner is an open-source command line tool that reports which components and props a codebase uses, and Omlet is a hosted product that tracks component adoption across repos over time. Our scanner adds the parts specific to us: lookalikes, ds- overrides and raw colors.

On the design side, Figma's library analytics report how often each component is inserted and detached (unlinked from the library so a designer can change it freely). Enterprise plans can pull the same numbers through Figma's Library Analytics REST API. A component that is detached a lot is telling you it's missing something.

The adoption scanner. On the left, the 70 repos' source code and the Figma files feed the scanner, which parses code into syntax trees and reads Figma instances. The scanner writes weekly records to a database. Below, the adoption dashboard shows coverage per team: payments 92%, search 64% and growth 38%, which is flagged. A second row shows 41 lookalike buttons, 312 one-off hex colors and 18% of Figma instances detached. A line explains coverage as system uses divided by system plus lookalike uses.70 repossource codeFigma filesinstancesscannerAST + Figma APIrecordsweeklyadoption dashboardpayments: 92%search: 64%growth: 38%lookalikes: 41one-off hex: 312detached: 18%coverage = system uses / (system + lookalike uses)
The scanner counts what teams actually ship, not what they install. Low coverage or many detached instances point at a gap in the system, which is where the core team should look next.

The headline metric is the coverage ratio: system component uses divided by system plus lookalike uses, per team. Pair it with the version spread (how many repos are on N, N-1, older) and the one-off color count. Publish it, but use it to find where the system is failing teams, not to rank them. A team with low coverage usually has a need the system doesn't meet yet.

Check your understanding · Exercise 13

The scanner shows that 40% of Dialog uses in the payments team's repos have CSS overrides on .ds-dialog__footer. What is the best reading? Select all that apply.

Accessibility and performance: what the system guarantees

A design system is the most effective accessibility investment a company can make: one correct combobox fixes every combobox. But teams often hear "the components are accessible" and conclude their pages are. They aren't automatically.

A perfectly accessible button with the label "Click here", a dialog opened with no heading, or a form whose errors are only red text still fail users. So the system writes down a clear line between what it guarantees and what remains the product team's job. The aria-patterns and visual-accessibility lessons cover the techniques.

The system guaranteesThe product team must still
Correct roles, states and properties for each component (from the WAI-ARIA patterns)Provide meaningful labels: the text of buttons, links, icon-button labels, dialog titles
Full keyboard support and visible focus styles in every themeKeep a logical page order and headings; don't remove focus outlines in product CSS
Focus management inside components: trap and return in dialogs, roving focus in menus and tabsMove focus sensibly between pages and after async actions (a saved form, a deleted row)
Token pairs that pass WCAG 2.2 AA contrast in all nine themesNot put text on images or custom colors that break contrast
Respect for prefers-reduced-motion and forced colors mode in every animation and styleNot add their own motion without the same checks
Form fields wire label, description and error message togetherWrite error messages that say how to fix the problem

Two practices make the line real. First, each component's docs page has an accessibility section that lists both columns for that component, so the responsibility is next to the example engineers copy. Second, the system ships tools for the product side too: the lint plugin warns on an icon-only Button without aria-label, and an axe test helper lets teams run the same rules on their own pages in their own CI.

Think first

A screen reader user reports that the date picker announces "button" for each day, with no date. Is this a system bug or a product bug, and how does the fix reach users?

Performance: never heavier than hand-written code

Teams will compare the system's cost against writing the component themselves. If importing one Button pulls 80 KB, the system loses that argument, and it deserves to. The goal is that using the system costs about the same bytes and main-thread time as a careful hand-written version, and usually less, because it's optimized once for everyone. The code-splitting lesson covers bundling in general.

  • ES modules, one file per component, so bundlers can drop what isn't imported (tree shaking). No single pre-bundled file.
  • sideEffects declared: the package marks only its CSS files as having side effects, so the bundler can safely remove unused JavaScript modules.
  • Subpath exports (@acme/ds-react/button) and no giant barrel file for icons: 1,200 icons are 1,200 modules. Barrel files slow dev servers even when production tree shaking works; Next.js's optimizePackageImports option exists for exactly this problem.
  • Size budgets per entry enforced in CI: Button 3 KB, Dialog 9 KB, DatePicker 18 KB (gzipped, counting what each adds beyond React and the shared behavior core, which has its own budget). A pull request that goes over has to justify it.
  • Heavy dependencies stay out of core: date math, virtualization and charting live in separate entry points that load only where used.

Decision How should component CSS reach the page?

One stylesheet for all components

Apps load @acme/ds-css/styles.css once, in the document head.

Pros

  • One cached request, shared by every page and every app version that uses the same release.
  • No ordering problems; works in Rails and Angular the same way.

Cons

  • About 30 KB gzipped even for a page that uses three components.

Per-component CSS imported by each component

Each component's JavaScript imports its own CSS file; the app's bundler collects only what's used.

Pros

  • Only the CSS that's used ships.

Cons

  • Depends on each consumer's bundler handling CSS imports from packages.
  • Order between chunks can vary, which is why layers matter.

What we pick: Both, with a default. The core stylesheet (about 30 KB gzipped, fingerprinted and cached for a year) is the default for apps that use more than a handful of components, which is most of them. Heavy, rare components such as the data grid and date picker ship their CSS with their own entry point. The two never style the same classes, and both sit in the ds.components layer below app CSS, so their load order doesn't matter.

Server Components and the client boundary

In React Server Components, components are server components by default. A module that starts with the "use client" directive marks a boundary: it and everything it imports run on the client too. Anything with state, effects, context or event handlers must be on the client side of that line.

A design system that marks everything as client forces all its users' pages into client code; one that marks nothing breaks on first use. So the system sorts components. Static ones (Text, Heading, Stack, Card, Badge, icons) have no hooks and no directive, so a server page renders them with zero client JavaScript. Interactive ones (Button with onPress, Dialog, Tabs, Select) start with "use client".

Two zones. The server zone, with no JavaScript shipped, holds a page server component and the static design system components: Card, Heading, Stack, Badge, Text and Icon. The client zone, whose modules start with use client, holds Button, Dialog, Tabs and Select. An arrow from the page into the client zone shows it passing serializable props to interactive components. A note says each client module has the directive at its top and that themes come from CSS variables, so no provider is needed.server: no JS shippedPageserver componentCardHeadingStackBadgeTextIconclient: use clientButtonDialogTabsSelectone directive per client module, at the topthemes are CSS variables: no provider needed
Static components have no hooks and no directive, so they cost zero client JavaScript. Only interactive ones start with "use client", one module each, so a page pays for exactly the interactivity it uses.

Two details catch library authors. Bundling all components into one file loses per-file directives, so the build must keep one output module per component with the directive at its top. And themes must not rely on React context for colors: because our themes are CSS variables chosen by a data-brand attribute on html, server components get the right colors with no provider at all.

Check your understanding · Exercise 14

A Next.js App Router page imports only Card, Heading and Badge from the system, but the client bundle includes 60 KB of system JavaScript. What could cause it? Select all that apply.

Running it in the interview

This question is open-ended, and the trap is spending 30 minutes on Button props. Interviewers want to see that you treat the system as a product with users, that you know the layers, and that you have a plan for change: versions, migrations and measurement. A 50-minute plan:

MinutesWhat to doWhat to draw or say
0 to 6Scope: teams, repos, frameworks, brands, existing kits, team sizeWrite the numbers down: 24 teams, 70 repos, 3 frameworks, 3 brands times 3 modes, 7 engineers.
6 to 12The layersDraw the stack: tokens, headless behavior, styled components, patterns, products. Say why each is separate.
12 to 22Tokens and themingThree tiers, DTCG files in git, Style Dictionary to four platforms, brand and mode sets, Figma sync.
22 to 30Components and stylingHeadless plus styled, composition, static CSS with layers, the framework plan.
30 to 40Shipping changePackages, semver rules for components, changesets, deprecation, codemods and the migration bot.
40 to 47Quality and adoptionThe CI gates, the scanner and coverage ratio, the contribution model.
47 to 50Wrap upName the two riskiest parts (upgrade tail, multi-framework drift) and how you'd watch them.

Follow-up questions to expect

Think first

Leadership asks you to mandate the system for all teams by next quarter. What do you say?

Think first

A big team says the system is too slow to change and wants to fork the components into their repo. How do you respond?

Think first

The company is moving to a new major version of React over the next year. How does the system handle it?

Common mistakes: designing only the React components and forgetting the 15 non-React repos; using primitive colors in components so theming breaks; one package per component with no plan for the version matrix; treating semver as "props only" and breaking Rails apps with a class rename; and saying "we'll write a migration guide" where the answer should be codemods and a bot. The strongest answers also say what they would not build in year one: a full web component library, a page builder, or 200 components.

Check your understanding · Exercise 15

You have 5 minutes left. Which points best show senior-level thinking? Select all that apply.

Summary

  • Design the system in layers that change at different speeds: tokens as data at the bottom, headless behavior, styled components, then patterns and product code.
  • Tokens live in git in the DTCG format, in three tiers (primitive, semantic, component). Components read only semantic tokens, so brands and modes are token sets, not forks.
  • Style Dictionary turns one token source into CSS variables, JavaScript, Swift and Android resources; Figma variables sync both ways, and git wins conflicts.
  • Use static CSS with custom properties inside cascade layers: zero runtime, safe in Server Components, usable from Angular and Rails, and product overrides win predictably.
  • A component's public API is wider than its props: documented classes, tokens, roles, names and keyboard behavior. Write down which changes are major, minor and patch.
  • Breaking changes ship with codemods and a bot that opens upgrade pull requests; deprecate in a minor, remove in a major, support N-1 for a fixed period.
  • Every pull request passes visual diffs across themes, axe, interaction tests and size budgets; manual screen reader testing gates stable releases.
  • Adoption is earned: a contribution path with an experimental stage, office hours, templates and lint, measured by a scanner that counts real uses, lookalikes, overrides and one-off colors.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next