Design System Library

The prompt

Interviewers ask this one in a single line: "How would you create or maintain a design system library?"

That is the whole prompt, and the second verb is the one that matters. Building a component library is a few weeks of work that most engineers can describe. Keeping one alive across many teams for several years is the harder job, and it is where this interview spends most of its time. Answer only the first half and you will have described a folder of components, not a system other teams depend on.

Some bad decisions early cause a lot of trouble later. I have seen companies build their design system library again because of choices they made earlier, or make a huge breaking change because they were really frustrated with the API design. There was no going back, because so many teams were already using it.

This question also has no screen behind it. There is no server to call, no data to fetch, and no client state to hold, so the delivery framework steps for API design, data fetching, data sending, and state management have nothing to work on. The framework is a default path, not a script, and this is one of the questions where you leave it. This walkthrough runs in three phases instead: what you build, how you ship it, and how you keep it alive.

Requirements and scoping

Start by asking questions to remove the vagueness. A design system for three engineers in one app and a design system for forty engineers across eight apps are different systems, so the answers here change almost every decision that follows.

Q

How many applications and teams will use this?

A

Around eight applications, six product teams, roughly forty frontend engineers.

Q

Is everything on the same framework?

A

Most products are React. There is also a marketing site on Astro and one older admin app on Vue that nobody wants to rewrite yet.

Q

Are we starting from nothing, or replacing something?

A

The library itself starts from nothing. But every team has built its own button, input, and modal over the years, and this will eventually replace those.

Q

Do those applications live in one repository, and do they deploy together?

A

Separate repositories, separate pipelines. Each team ships on its own schedule.

Q

Is there a design team, and where do the visual decisions live today?

A

Yes. Design owns the colors, spacing, and typography, and they keep them in Figma.

Q

Do we need theming?

A

Light and dark for all products.

Q

What accessibility level do we need, and who is responsible for it?

A

AA. Product teams keep getting it wrong on their own components, so the system should make it hard to get wrong.

Q

Do any of the products server render?

A

Our internal applications and dashboards are in React. Our main consumer facing site runs on Next.js.

Q

How many engineers do I get to build and run this?

A

Three.

Q

Will product teams contribute components, or only consume them?

A

They will want to. Today they just build their own when the shared one does not fit.

Two answers shape everything that follows. We write the library from scratch, but it has to replace what eight apps already have, so migration and adoption are part of the design rather than something to figure out later. And it is three people serving forty, so anything that only works when the central team does it by hand will not survive.

What a design system is made of

Say what is in scope before designing anything, because "design system" means different things to different people. A design system library is not a folder of React components. Five things ship together:

  • Design tokens. Named values that stand for design decisions: a color, a spacing step, a font size, a shadow. color-action-primary is a token; #2563eb is the value behind it.
  • Primitives. The base components everyone needs: Button, Input, Checkbox, Modal, Menu, Tooltip.
  • Composed components. Patterns built from primitives: FormField, DataTable, Pagination.
  • Documentation. What each component is for, when not to use it, what the keyboard does.
  • Tooling. The lint rules, tests, and release pipeline that keep the other four honest.

They stack, and the dependencies point one way only:

tokens → primitives → composed components → product patterns

Tokens know nothing about components. Primitives use tokens. Composed components use primitives. The first three ship from the design system library. Product patterns are the exception: they are built out of library components but live in each product's own codebase, because they encode how that one product works.

That split is the boundary interviewers push on: where does the design system library stop? My rule is that the library owns anything two or more products need, and nothing that encodes a single product's business rules. A FormField that pairs a label, an input, and an error message belongs in the library, because every product has forms. A CheckoutAddressForm that knows which countries need a state field belongs in the checkout product, because only checkout has that rule.

Design tokens: the real contract

This is where I would start building, and it is the closest thing this question has to API design. Tokens are the contract every product depends on, so their names are a public API in the same way function names are. Once a team writes var(--ds-color-action-primary) in their CSS, renaming that token breaks their page silently: an undefined custom property is not an error, so the property just falls back to its inherited or default value and the color comes out wrong.

I would organize them in three tiers:

global blue-500, gray-200, space-4
semantic color-action-primary, color-text-secondary
component button-background, input-border

Global tokens are the raw palette, an internal implementation detail that products should never use directly. Semantic tokens name a role rather than a value, and they are the public API. Component tokens are the decisions one component makes, and they double as the sanctioned way to restyle it.

The reason to insist on semantic names shows up at a rebrand. If products wrote --ds-blue-500, a rebrand is a search and replace across eight repositories that nobody can verify. If products wrote --ds-color-action-primary, a rebrand changes one line in the token source and every product picks it up on its next upgrade.

The source of truth is one file, not one per platform. I would write tokens in the DTCG format, the interchange format the Design Tokens Community Group is standardizing so design tools and code can share one file, and generate every output from it with Style Dictionary, a tool that reads one token source and writes per-platform files:

{
"color": {
"brand": {
"primary": { "$type": "color", "$value": "#2563eb" }
},
"action": {
"primary": {
"$type": "color",
"$value": "{color.brand.primary}"
}
}
}
}

That single source produces the CSS custom properties the web apps use, a TypeScript file for the few places that need a value in JavaScript, and iOS and Android outputs later if mobile asks, without forking the definitions.

CSS custom properties are the delivery mechanism, and that choice is what makes the Astro site and the Vue admin possible at all. A custom property is just CSS, so every framework consumes the same file with no build step and no adapter. It also inherits into Shadow DOM, the isolated styling scope a custom element creates, which a JavaScript theme object cannot reach.

:root {
--ds-color-action-primary: #2563eb;
--ds-color-text-primary: #0f172a;
--ds-space-4: 16px;
--ds-radius-md: 6px;
}

Deprecation belongs in the contract too. The DTCG format carries a $deprecated field, so a token on its way out can name its replacement and its removal date in the token file itself. The versioning section covers what we do with that.

Component API design

Tokens keep products looking the same. Component APIs decide whether teams use the components at all or quietly copy them, so this is where I would spend the most interview time.

Composition over configuration. The failure mode is a component that grows a prop for every request. A Card accumulates title, subtitle, media, footer, actions, badge, onDismiss, until nobody can tell which combinations are legal. Ship the pieces instead:

<Card>
<CardMedia src={cover} />
<CardHeader>Quarterly report</CardHeader>
<CardBody>Revenue grew 12 percent.</CardBody>
<CardActions>
<Button>Open</Button>
</CardActions>
</Card>

A team that needs an arrangement we never thought of now rearranges the pieces instead of asking us for a prop.

Keep the variant count small on purpose. A Button with 4 variants, 3 sizes, 3 icon positions (none, leading, trailing), a loading state, and a disabled state is 144 combinations, and nobody has tested 144 of anything. Before adding a variant, ask which products need it, which existing variant is closest, and whether composition already covers it.

Support controlled and uncontrolled. Anything holding internal state needs both. Uncontrolled is the short version for the common case; controlled is the escape hatch for the product that needs to drive the state itself.

<Dialog defaultOpen={false} />
<Dialog open={isOpen} onOpenChange={setIsOpen} />

Be consistent across the whole set. Inconsistency is a tax every consumer pays. One word for visual style on every component, never variant on one and appearance on another. One pattern for event handlers. One way to express size. The same names for disabled, loading, and error everywhere. I would write that down as an API checklist and make it part of review, because forty components drift in forty small steps.

Give people a way out that is not a copy. Every component needs sanctioned customization: the component tokens above, slots for content we cannot predict, and an asChild prop so a Button can render as a link without us adding an as prop that has to type-check every element. When those exist, a team with an unusual need bends the component. When they do not, the team copies the file into their repo and stops receiving our accessibility fixes forever.

Build the primitives or take them. For Modal, Menu, Combobox, and Tooltip the hard part is not the markup, it is focus management, keyboard interaction, and screen reader announcements. With three engineers I would build on a headless library such as Radix or React Aria, which supply behavior and no styling, and spend our own time on tokens, theming, and the release pipeline that nobody else can do for us. The cost is that we inherit someone else's breaking changes, so we wrap their components rather than re-exporting them, which keeps our API stable when theirs moves.

One thing a library component never does is fetch its own data. A component that calls an endpoint cannot be used by a product with a different backend, cannot be tested without a network, and cannot be server rendered predictably. Components take data as props.

Theming

We need light and dark. A theme is a different set of values for the same token names, so both live in the token package:

:root {
--ds-color-surface: #ffffff;
--ds-color-text-primary: #0f172a;
}
[data-theme='dark'] {
--ds-color-surface: #0f172a;
--ds-color-text-primary: #f1f5f9;
}

Components never know which theme is active. They read var(--ds-color-surface) and the browser resolves it from whichever block applies. A third theme therefore costs no component changes at all. What it does cost is contrast checking and a wider screenshot matrix, and the testing section comes back to that.

The application owns theme selection and sets the attribute; the design system owns the token values. Splitting it that way lets a product add a theme switcher without us releasing anything.

Two details interviewers ask about:

  • No flash on load. With server rendering, the theme has to be on the HTML element before the first paint, which means reading the stored preference in a small inline script in the document head rather than in a React effect. Otherwise every visitor sees light mode for a frame.
  • Crossing boundaries. Custom properties inherit into Shadow DOM, so if a product wraps our components in a custom element, theming keeps working with no extra wiring. A theme object passed through React context stops at that boundary. An iframe is a separate document, so nothing inherits into it and the token stylesheet has to be loaded inside it as well.

Packaging and the repo

The design system lives in its own repository, and inside it every layer is a real package:

packages/
tokens/
icons/
react-primitives/
react-components/
stylelint-config/
eslint-config/

Splitting it this way is not tidiness. It is what lets a consumer install tokens without pulling in React, and lets each package move at its own speed.

Notice there is no vue-components package. Three engineers cannot maintain two component implementations, and the Vue admin is one legacy app nobody plans to keep. It gets tokens, which cost us nothing extra and buy most of the consistency. If a second Vue product ever appears, that is the point to revisit it.

The rule that matters: dependencies point one way and the graph never loops. react-components may depend on react-primitives, icons, and tokens. tokens depends on nothing. The day someone adds a React import to the token package to move faster, the Vue app and the Astro site can no longer use tokens, and it takes a while for anyone to notice.

Products consume published packages, not paths inside our repository. In a shared repo it is easy to reach past the public entry point into an internal file, and once one team does that our internals have become everyone's API. The monorepos and choosing a repo articles cover this decision in more depth.

For styling inside the library I would use a zero-runtime approach: styles written next to the component and compiled to plain CSS at build time, either CSS Modules or a library like vanilla-extract. Nothing about theming needs to run in the browser, because the custom properties do that work in the cascade. The styling approaches article compares the options.

Distribution

Now the delivery question: how do eight applications in eight repositories actually get this?

HowWhat it costs
Versioned npm packagesTeams upgrade when they choose, so production carries a long tail of versions
Module FederationEveryone is on the latest immediately, and so is everyone affected by a bad release
A CDN bundleTrivial to consume, impossible to tree-shake
Web ComponentsWorks in every framework, but Shadow DOM blocks the customization teams expect

I would ship versioned npm packages. Teams already deploy independently, and an upgrade a team chooses and tests is safer than one that arrives while they are asleep. Version fragmentation is the real cost of that choice, and the sections on versioning and on keeping the library healthy are how you hold it down.

Runtime sharing has one good use here: something that has to be identical across products at the same moment, like a global navigation bar. That is a small, deliberate exception, not the delivery model. If we did federate, React has to be configured as a shared singleton with a required version, because two copies of React on one page produce broken hooks and context that silently reads the wrong provider. The microfrontends article covers the wider pattern.

Web Components deserve a mention, since they are the only option that gives one implementation for every framework. I would not start there. The Astro site renders React directly, so the Vue admin is the only consumer that could not use our components, and it is the one app nobody plans to keep. Buying framework independence for it means paying the Shadow DOM cost on all eight.

Versioning and releases

Semantic versioning only means something once you have said what the public API is. For us it is: token names and their meaning, the generated custom properties, component props and their behavior, the sanctioned styling hooks, and the keyboard and screen reader behavior of every component.

The rules fall out of that list:

  • Renaming a token is a major, precisely because nothing catches it. No build fails, no test fails, the colors are just wrong in production.
  • Changing what a keyboard key does is a major, even though no type signature changed. Someone's muscle memory and someone's test both break.
  • Adding a token or an optional prop is a minor.
  • Fixing a component that announced the wrong thing to a screen reader is a patch.

For the mechanics I would use Changesets rather than invent a release ritual. Every pull request that changes behavior carries a small file declaring the bump type and a human sentence about what changed. On merge, CI works out which packages need bumping, updates the dependencies between them, writes the changelogs, and publishes.

Removals happen in two steps, never one. Deprecate in a minor release with the replacement named and a removal date attached, then remove in the next major. In between, the deprecation is visible in three places: the token metadata, the TypeScript types, and a lint rule that flags it inside the product's own CI.

Two things make a major release survivable across eight repositories:

  • A codemod for anything mechanical. Renamed props, moved imports, renamed tokens. A migration that is a documented list of forty changes does not happen. A migration that is one command does.
  • A compatibility window. Support the current major and the previous one, with a stated number of days for teams to move. That is the difference between "we deprecated it" and "we deprecated it and helped you move."

Before a major goes out I would publish it to a canary channel, a prerelease tag that only teams who opt in will install.

Performance and bundle size

This failure mode is slow and invisible. Components accumulate, nothing is ever removed, and eventually the design system is a large share of every product's JavaScript with most of it unused.

  • Declare sideEffects in package.json so bundlers can drop the components a product never imports. Do not declare false outright, because our components import their own CSS and the bundler would drop the stylesheets too. Declare the exceptions: "sideEffects": ["*.css"].
  • Offer per-component entry points alongside the barrel export, the index.ts that re-exports everything, so @ds/react/Button is available to teams who want to be explicit. The performance at scale article covers how barrel files defeat tree shaking.
  • Put a size budget in CI and let it fail the build like a test. A number nobody enforces drifts upward every sprint. The performance budgets article covers setting them.
  • Do not ship 300 icons to everyone. Icons are individual modules imported one at a time, never a single object holding all of them.
  • Watch for two copies on one page. In a product that composes several bundles, two versions of the library means duplicate custom property definitions and double the JavaScript. The microfrontend performance problems article covers detecting it.

Server rendering is the other half, and the rule is narrower than people expect. A component needs the 'use client' marker as soon as it takes an event handler as a prop, because a function cannot be sent from the server to the browser. That covers Button, not just the obviously stateful components, which is why most published libraries mark nearly every interactive export. What stays on the server is the genuinely inert set: layout, typography, cards, badges. Marking components individually rather than marking the whole package keeps that split intact.

Accessibility

We were asked for AA and told the system should make it hard to get wrong, so the answer is a division of responsibility. The library owns behavior: focus order, focus trapping and restoration in dialogs, keyboard patterns, roles, and announcements. The product owns content: the label on the button, the alternative text on the image, the reading order of the page.

That split is what fixes the problem the interviewer described. Every team is re-solving focus management for its own modal, and getting it slightly wrong each time. Solve it once, correctly, in the component, and eight products get it by upgrading.

Contrast is the other half, and it belongs in the tokens rather than the components. AA asks for a ratio of 4.5 to 1 between body text and its background, and 3 to 1 for large text and for the edges of controls. Because our semantic tokens come in pairs, color-text-primary on color-surface, we can check those pairs once and guarantee the ratio for anyone who uses them. That check runs per theme, since a pair that passes in light can fail in dark. A product that composes tokens correctly then cannot build a low-contrast button, which is exactly the "hard to get wrong" the interviewer asked for.

Because behavior is part of the contract, an accessibility fix is versioned like any other change. Changing Escape from closing the menu to closing the whole dialog is a major, because someone's tests and someone's users relied on the old behavior.

One more detail: a component can pass every check in isolation and still be broken on a real page, where three regions compete for focus after a route change. That is why accessibility is tested in the assembled product too, not only in the component library.

Testing and documentation

Four layers, each catching something the others cannot. The testing strategy article covers how to divide the work.

  • Unit tests for pure logic: variant-to-token resolution, the date maths inside a date picker.
  • Interaction tests for keyboard and state: Escape closes the menu and returns focus to the trigger, arrow keys move through options, Enter selects.
  • Visual regression for what the other tests cannot see. Every component, in every theme, screenshotted and compared on each pull request. This layer catches a token change quietly altering the border color of nine components.
  • Contract tests for the consumers. Build two or three real product applications against the release candidate before publishing. A green library suite tells you the library works; only this tells you the upgrade will not break anyone.

Every component gets snapshotted in both themes, which doubles the matrix, and every theme has to be contrast checked separately. That is the real cost of a third theme: not component changes, but another full pass of both. The specialized testing patterns article covers cases beyond ordinary feature tests.

Documentation lives next to the code and is generated wherever possible. Prop tables and examples come from the source, so they cannot drift, and the written part covers only what a generator cannot know: when not to use a component, what the keyboard does, what changed in the last major. Documentation that disagrees with the code is worse than none, because people trust it.

Adoption and enforcement

Now the second half of the prompt. Everything above is worth nothing if teams keep using their own button, and eight apps with their own buttons is exactly where we started.

Adoption is not solved by a launch announcement. It is solved by making the shared component the easy choice and the local one the annoying choice, in that order. Rules with no tooling behind them are suggestions.

The enforcement ladder, climbed one rung at a time:

  1. Guidance. Documentation and design review. Nothing is automated.
  2. Lint warnings. A raw hex value or a bare <button> shows a warning in the editor and in CI. Nothing blocks.
  3. Review annotations. The warning appears in the pull request, with a link to the component that replaces it.
  4. Required checks. Now it fails the build.
  5. Time-bounded exceptions. A team that genuinely needs to deviate gets an exception with an owner, a reason, and an expiry date.

The order matters more than the rungs. Failing a team's build for using a raw hex before we have shipped the token they need makes us the reason they cannot ship, and that is a reputation a design system does not recover from. Move up a rung only once the library covers the cases teams actually have.

Because this replaces what teams already have, I would stage the migration rather than ask eight apps to swap everything at once. Tokens first, since they are framework-agnostic and buy visible consistency for about a day of work per app. Then primitives, one component at a time, starting with Button because it is everywhere and the easiest to prove. Old and new coexist during the move, and there is no flag day.

Keeping it healthy

A design system decays by accumulation, and every failure mode below is what happens by default when nobody is tending it.

  • Component proliferation. Variants and props pile up until nothing is testable. Require a case before adding a variant, and delete the ones nothing uses.
  • Forking. Teams copy components when our backlog is full, so a rising fork count is a verdict on our intake speed rather than on their discipline. Keep a register of known forks, otherwise they are invisible until someone finds one a year later.
  • Version fragmentation. Everyone upgrades on their own schedule, so nothing is ever true of all products at once. Track which product runs which version, publish an expectation such as no more than one major behind, and make upgrades cheap with codemods.
  • Token sprawl. color-primary, color-primary-light, color-primary-subtle, color-primary-muted. Audit regularly and merge duplicates before nobody can choose between them.
  • Design and code drift. Figma says 8px, the token says 6px. Pick one side as the source of truth and sync from it, and use identical names on both sides so a conversation about "elevated" means one thing.

The team decays too. Three people cannot own forty components and every request from six product teams, so ownership is tiered. We own tokens, themes, and primitives, with full review. Product teams own composed components built from our primitives, and we review the API and the accessibility. Anything experimental is clearly labeled and lightly reviewed. Weekly office hours resolve more than a ticket queue does, because most questions turn out to be "I did not know it could already do that."

Finally, know whether it is working. Six numbers, reviewed monthly, are enough to start:

  • What share of the UI uses design system components.
  • How far behind the current version each product is.
  • How many contributions came from outside the team.
  • How many known forks exist, and whether that number is rising.
  • How long a component takes to go from request to release.
  • Bundle size over time.

Watch the fork count and the time to release together. Teams fork when waiting on us costs more than copying, so a rising time to release is the thing to fix before the fork count answers it.