Design systems
A design system is a shared contract
A component library supplies reusable UI code. A design system adds the decisions around that code: named design values, supported component behavior, usage documentation, accessibility requirements, and a release contract. The consuming application still owns its product workflow and connected logic.
Consider a shared button. The library can own its appearance, disabled behavior, loading presentation, and HTML semantics. The checkout application owns what happens after activation.
// component library
export function Button({ children, disabled, loading, onClick, ariaLabel }) {
const unavailable = disabled || loading;
return (
<button
type="button"
aria-label={ariaLabel}
aria-busy={loading || undefined}
disabled={unavailable}
onClick={onClick}
>
{loading ? "Loading..." : children}
</button>
);
}
// application
<Button disabled={!order.canSubmit} onClick={() => submitOrder(order.id)}>
Place order
</Button>
React calls the values passed to a component props. Nested JSX arrives through the children prop, which lets the caller compose content without the library knowing every future label or icon.
Wrong "A shared button should know which page it is on and call the right API."
Right "The button owns reusable UI behavior. The application supplies product state and the action."
Design tokens give decisions names
The Design Tokens Community Group defines a token as information associated with a human-readable name, at minimum a name and value. A token can also have a type and description. The 2025.10 format supports aliases, where one token references another.
This allows a component to ask for a purpose instead of a fixed brand color:
{
"palette": {
"blue-600": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [0.094, 0.349, 0.788],
"hex": "#1859c9"
}
}
},
"action": {
"primary": {
"background": {
"$type": "color",
"$value": "{palette.blue-600}"
}
}
}
}
The button consumes action.primary.background. Another theme can preserve that semantic name and point it at a different palette token. Components do not need a brandBlue prop or a copy of the hex value.
Wrong "Tokens are CSS variables with shorter names."
Right "Tokens record named design decisions in a platform-agnostic source format. A translation tool may turn them into platform-specific output."
A theme is a complete set of values for the semantic contract the components consume. A dark theme cannot stop at changing a page background. It must provide the semantic values required by every supported component state.
Stories document supported states
Storybook defines a story as a captured rendered state of a UI component. Args describe the inputs for that state. Useful button stories cover supported behavior. A gallery of arbitrary styling combinations obscures which states the library supports.
export const Default = {
args: { children: "Save" },
};
export const Disabled = {
args: { children: "Save", disabled: true },
};
export const Loading = {
args: { children: "Saving", loading: true },
};
Storybook Autodocs can infer metadata such as args and arg types and generate a component documentation page. The prose still needs to explain when to use the component, what states are supported, and what the consumer must supply.
Accessibility belongs in the component API
WCAG 2.2 requires the name and role of user interface components to be programmatically determinable, and requires content functionality to be operable through a keyboard interface, apart from its path-dependent exception. A native button already carries button semantics and browser-provided keyboard behavior. An icon-only button still needs a name.
<Button ariaLabel="Close dialog">
<CloseIcon aria-hidden="true" />
</Button>
Storybook's accessibility addon can check rendered stories. When the Vitest addon or test runner runs those checks, parameters.a11y.test = "error" makes violations fail the test in CI. Automated checks are a gate, not proof that every user can operate the component. Keyboard behavior and the accessible name still need direct review.
The token graph is the theming architecture
The 2025.10 Design Tokens Format distinguishes tokens, groups, types, and aliases. Groups organize tokens, but tools should not infer type or purpose from a group name. Put that meaning in the token's type and in the names your components consume.
A practical theme contract has two useful layers:
{
"palette": {
"$type": "color",
"brand-600": {
"$value": {
"colorSpace": "srgb",
"components": [0.094, 0.349, 0.788],
"hex": "#1859c9"
}
},
"neutral-0": {
"$value": {
"colorSpace": "srgb",
"components": [1, 1, 1],
"hex": "#ffffff"
}
}
},
"semantic": {
"$type": "color",
"action-primary-background": { "$value": "{palette.brand-600}" },
"action-primary-text": { "$value": "{palette.neutral-0}" }
}
}
Palette tokens name available values. Semantic tokens name jobs in the interface. Components depend on the semantic layer:
.button-primary {
color: var(--semantic-action-primary-text);
background: var(--semantic-action-primary-background);
}
For another brand or mode, keep the semantic paths and replace their references. This makes missing theme coverage detectable: every supported theme must resolve every public semantic token.
Wrong "Dark mode is a boolean prop that each component uses to choose between two hex values."
Right "A theme supplies the semantic token contract. Components ask for roles such as action background and text."
Token indirection does not establish accessibility by itself. Test the resolved combinations in the component states where they appear.
Component APIs are product-independent public APIs
React components receive props, including arbitrary JavaScript values, and nested JSX is passed through children. Use that composition boundary before adding props tied to current screens.
type AlertProps = {
tone: "info" | "warning";
title: string;
children: React.ReactNode;
onDismiss?: () => void;
};
export function Alert({ tone, title, children, onDismiss }: AlertProps) {
return (
<section data-tone={tone}>
<h2>{title}</h2>
<div>{children}</div>
{onDismiss && <button type="button" onClick={onDismiss}>Dismiss</button>}
</section>
);
}
The application composes business-specific content and decides what dismissal means:
<Alert
tone="warning"
title="Payment needs attention"
onDismiss={() => hideBillingNotice(account.id)}
>
Update the card for account {account.name}.
</Alert>
Adding accountId, billingPlan, and currentRoute to the library component would couple its API to one product. Storybook's page guidance makes the same boundary visible: presentational components can receive their data as args, while connected components require network, module, or provider mocks.
The contract is larger than the TypeScript type. Rendered HTML semantics, keyboard behavior, controlled and uncontrolled state rules, token names, and documented states can all affect consumers.
Storybook is executable documentation
A story captures one rendered state. Write stories for meaningful state boundaries such as empty, disabled, loading, error, long content, and icon-only use when the component supports them.
const meta = {
component: Button,
tags: ["autodocs"],
parameters: { a11y: { test: "error" } },
};
export default meta;
export const IconOnly = {
args: {
ariaLabel: "Close dialog",
children: <CloseIcon aria-hidden="true" />,
},
};
Autodocs infers args, arg types, and parameters. Add written guidance for constraints that cannot be inferred from a type, such as when an icon-only control needs a name or which party owns state.
Wrong "If the default story looks correct, the component is documented."
Right "Stories enumerate supported rendered states. Documentation also explains the component's purpose, constraints, and ownership boundary."
Accessibility is a release requirement
W3C APG says native HTML form elements receive browser keyboard support, while custom GUI components made accessible with ARIA require authors to implement keyboard support. Prefer native semantics when they match the interaction. A custom tab interface, menu, or grid needs the corresponding focus and keyboard behavior, not only an ARIA role.
Automated accessibility scans inspect rendered DOM against rules. Storybook documents that its addon uses axe-core and catches only a subset of WCAG issues. Pair the automated gate with keyboard review and assistive-technology checks for complex widgets.
Version the contract consumers use
Semantic Versioning requires a declared public API. For releases after 1.0.0, backward-compatible fixes increment patch, backward-compatible additions increment minor, and incompatible public API changes increment major. SemVer also requires a minor increment when public API functionality is marked deprecated.
Deprecate with a replacement before removal:
type ButtonProps = {
/** @deprecated Use `tone="danger"` instead. */
destructive?: boolean;
tone?: "neutral" | "danger";
};
Publish the deprecation and migration instruction in a minor release. A later release that removes destructive is major because existing consumers can no longer use the declared API.
Define the public surface before choosing release numbers
Semantic Versioning works only after the package declares a precise public API. For a design system, that declaration should cover more than exported function names. Consumers can depend on component props, documented behavior, rendered semantics, supported states, and published token names.
Write the boundary down in machine-readable types and human-readable release policy:
export type PublicDesignSystemContract = {
components: "exported components and their props";
behavior: "documented state and interaction rules";
semantics: "documented HTML and accessibility behavior";
tokens: "published semantic token names and types";
};
That type is an illustrative checklist. TypeScript does not use it to enforce compatibility. Compatibility review still has to compare the old and new consumer-visible contract.
Wrong "Changing a token name is a patch because the new token resolves to the same color."
Right "If consumers reference the published token name, removing it is an incompatible public API change."
SemVer 2.0.0 assigns deprecation of public API to a minor release and incompatible removal to a major release. Its FAQ recommends documenting the deprecation and providing at least one minor release before removal.
export type BannerProps = {
/** @deprecated Use `importance="critical"`. */
urgent?: boolean;
importance?: "normal" | "critical";
};
The migration can span releases: add importance, document the mapping, measure remaining urgent use, then remove urgent in a major version.
Themes are implementations of one semantic contract
DTCG aliases express relationships between tokens, and groups may inherit through $extends in the 2025.10 format. Keep components on stable semantic names while each brand and mode supplies a complete implementation. The palette groups referenced below are omitted from the excerpt.
{
"semantic-base": {
"$type": "color",
"surface-page": { "$value": "{palette.neutral-0}" },
"text-primary": { "$value": "{palette.neutral-900}" },
"action-primary-background": { "$value": "{palette.brand-600}" }
},
"brand-b-dark": {
"$extends": "{semantic-base}",
"surface-page": { "$value": "{brand-b.neutral-950}" },
"text-primary": { "$value": "{brand-b.neutral-50}" },
"action-primary-background": { "$value": "{brand-b.accent-400}" }
}
}
The format specifies that local values override inherited values at the same path, and conforming tools must report unresolvable references. Theme completeness is a separate design-system contract, so add a coverage check that requires each supported theme to resolve every public semantic token. Accessibility still depends on the resolved UI state. A valid alias graph can produce a poor text and background combination.
Avoid component-level theme branches:
// Theme logic leaks into every component.
const background = dark ? "#7aa2ff" : "#1859c9";
// The component consumes one semantic decision.
const background = "var(--semantic-action-primary-background)";
The second form leaves brand and mode selection at the theme boundary and keeps the component contract stable.
A library cannot absorb the application
Storybook documents two distinct shapes at the page level: presentational pages can encode inputs as args, while connected components depend on services, modules, or providers that must be mocked. That gives a useful library boundary.
Shared components may own interaction patterns and reusable state. Applications own domain policy and live service connections.
// library
<Dialog open={open} onOpenChange={setOpen} title="Delete report">
{children}
</Dialog>
// application
<DeleteReportDialog
report={report}
canDelete={permissions.canDeleteReport}
onConfirm={() => api.deleteReport(report.id)}
/>
The application wrapper can compose the library dialog. Moving permissions or api.deleteReport into the shared dialog would make unrelated consumers inherit one product's domain model.
Wrong "A component belongs in the library as soon as two applications contain similar markup."
Right "A library component needs a reusable contract. Similar screens can still encode different product policy."
Documentation and tests share the same fixtures
Storybook stories are rendered states with args. Autodocs derives reference pages from story metadata, and MDX can add explanation where generated reference material is insufficient. Treat a supported story as a maintained example of the contract.
export const EveryTheme = {
render: (args) => (
<>
<Theme name="brand-a-light"><Button {...args} /></Theme>
<Theme name="brand-a-dark"><Button {...args} /></Theme>
<Theme name="brand-b-light"><Button {...args} /></Theme>
<Theme name="brand-b-dark"><Button {...args} /></Theme>
</>
),
args: { children: "Continue" },
parameters: { a11y: { test: "error" } },
};
Storybook can apply theme providers through decorators. When Storybook's accessibility tests run in CI, violations fail for stories configured with a11y.test = "error". Keep exceptions visible. The documented todo mode reports warnings in the Storybook UI but does not produce CI errors, so it must not quietly become the default for supported components.
Automated scans do not cover the whole accessibility contract. APG documents keyboard conventions and focus behavior for interactive widgets. Review complex components against their pattern's keyboard model and verify the accessible name that browsers calculate.
Adoption is a migration system
A release is available; adoption happens when consumers move. Track the public APIs that consumers still use, especially deprecated tokens and props. Give each deprecation a documented replacement and keep old and new forms compatible during the migration window.
export const migrationStatus = {
"checkout-web": { deprecatedButtonProps: 0 },
"admin-web": { deprecatedButtonProps: 7 },
"account-web": { deprecatedButtonProps: 2 },
};
The object illustrates a measurable inventory; no specific tool or schema is required. The useful signal is remaining usage per consumer. It tells maintainers whether a major removal is operationally affordable and tells application owners what work remains.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.