A design system is shared tokens, components, docs and rules. Learn tokens and themes, component APIs that are hard to use wrongly, safe changes with version numbers, and who decides.
In the patterns stage, we built parts that other people use. Part 38 made an accordion from small parts. Part 43 let a button draw a link. Part 45 gave a card named places for content. Then Parts 46 to 48 made them work with a keyboard and a screen reader, and Parts 49 and 50 tested them.
Now picture fifty teams using those parts in fifty apps. One team wants a new color. Another wants to rename a prop. A third team quietly copies the button and changes it. A year later, there are three different buttons.
A design system is how a company stops that from happening. This part covers four things. What goes into a design system. How to build its components so they are easy to use. How to change it without breaking other teams’ apps. And who decides what goes in.
Try this first
Read this code. Don’t press Run yet.
import { useState, type ReactNode } from 'react'
// The tokens: every color and space, in one place.
const colors = {
light: { page: '#ffffff', text: '#1f2933', brand: '#0f766e', onBrand: '#ffffff' },
dark: { page: '#111827', text: '#f9fafb', brand: '#5eead4', onBrand: '#111827' },
}
const space = { small: '4px', medium: '8px', large: '16px' }
function vars(prefix: string, values: Record<string, string>) {
return Object.entries(values).map(([name, value]) => `--${prefix}-${name}: ${value};`).join(' ')
}
const css = `
[data-theme="light"] { ${vars('color', colors.light)} ${vars('space', space)} }
[data-theme="dark"] { ${vars('color', colors.dark)} ${vars('space', space)} }
.ds-page { background: var(--color-page); color: var(--color-text); padding: var(--space-large); }
.ds-card { border: 1px solid var(--color-brand); padding: var(--space-medium); margin-bottom: var(--space-medium); }
.ds-button { background: var(--color-brand); color: var(--color-onBrand); padding: var(--space-small) var(--space-large); border: none; }
`
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="ds-card">
<h2>{title}</h2>
{children}
</section>
)
}
function Button({ children, onClick }: { children: ReactNode; onClick?: () => void }) {
return <button className="ds-button" onClick={onClick}>{children}</button>
}
export default function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const next = theme === 'light' ? 'dark' : 'light'
return (
<div className="ds-page" data-theme={theme}>
<style>{css}</style>
<Card title="Your order">
<p>Two books, ready to ship.</p>
<Button>Pay now</Button>
</Card>
<Button onClick={() => setTheme(next)}>Switch to {next}</Button>
</div>
)
}
There are three components here: Card, Button and App. Make a guess. When you press “Switch to dark”, how many of them get a new prop that tells them the colors?
Press Run, then press “Switch to dark”.
The background, the text, the card’s border and both buttons change. A thin white edge stays around them. That is the result box’s own margin, outside our <div>. But none of the components got a color prop. Only one thing changed: the data-theme attribute on the outer <div>, from light to dark.
The colors live in one object, colors, which the code turns into CSS. The components only ask for names like --color-brand. When the names point to new values, the components change.
What a design system is
A design system is a set of shared things that many teams use to build their apps. It usually has four parts:
- Tokens: the basic design choices, saved as named values. A color, a space size, a text size.
- Components: the parts built from those tokens, like
Button,TabsandAccordion. - Docs: pages that say when to use each part, and when not to.
- Rules: how the system changes, and who decides.
It serves two groups. The teams who build apps get parts that already work. The people who use those apps see the same button act the same way everywhere. The GOV.UK Design System’s docs say its contents must “meet user needs”.
Design tokens: design choices as data
A design token is a design choice with a name. The Design Tokens Community Group, a group that meets at the W3C, wrote a shared file format for tokens. Its report says a token is, at least, “a name/value pair”. Its own example is color-text-primary: #000000. The report is not a W3C standard. It is the community group’s own format.
Names that point to other names
The format allows a token’s value to “be a reference to another token”. It calls the second name an alias, which means another name for the same thing.
One way to use this is in layers. GitHub’s design system, called Primer, does this. Its base color tokens “map directly to a raw value”, like a dark teal, a blue-green, #0f766e. Its functional tokens name a job, like the color of text or of a border, and point to base tokens. Primer says base tokens “should never be used directly in code or design”.
So a button uses a name like color-brand. In the light theme, color-brand points to one base color. In the dark theme, it points to another. The button never knows the difference.
From tokens to CSS custom properties
In “Try this first”, vars turns each token into a CSS custom property. That’s a name with two dashes in front, like --color-brand: #0f766e;. CSS reads it back with var(--color-brand).
MDN, a site of web guides, says custom properties made with two dashes get “their value from their parent”. So we set them once, on the outer <div>. Every tag inside it can read them.
Each theme is one block of CSS that sets the same names to different values. Changing the attribute picks the other block. Each value is written in exactly one place, a single source of truth.
We checked this in a real browser, Chromium. We took the HTML that React made for “Try this first”, in both themes. The “Pay now” button’s background was rgb(15, 118, 110) in light and rgb(94, 234, 212) in dark. Those are #0f766e and #5eead4.
Tokens only reach code that uses them. A badge with background: #0f766e written into it stayed rgb(15, 118, 110) in the dark theme. So the system’s components always use a token.
In the community group’s format, tokens live in a JSON file, outside the code. Tools can turn that one file into CSS and into code for other platforms.
Starting with the user’s choice
People can set their phone or computer to light or dark mode. MDN says the prefers-color-scheme check tells you “if a user has requested light or dark color themes”. In React, read it once, for the first value of the theme state:
import { useState } from 'react'
export function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
)
return [theme, setTheme] as const
}
The function inside useState runs only for the first value (Part 4). We tried the matchMedia line in Chromium. With its color scheme set to dark, the line gave true. Set to light, it gave false.
One more CSS line helps. MDN says the color-scheme property lets the browser change “the default colors of form controls” and scroll bars. So add color-scheme: dark; to the dark block, and the browser’s own parts turn dark too.
A token change, step by step
Tokens ship in the design system’s package, with a version number, like the components do. Here is what happens when the design team changes one token.
One token change reaches every screen. Press play, or step through it.
The same steps in words:
- One token,
--color-brand, holds teal. Three components use it:Button,TabsandLink. - The design team changes that one token to purple, and ships a new version.
- Every component that reads
var(--color-brand)draws purple, and so does every screen that uses them. The browser works this out in one pass. Nobody changed the screens’ code. - One edit reached every screen of every app that updates to the new version.
A token change is not always safe. A new color can make text hard to read. So token changes go through the same checks as code changes. We come back to checks near the end.
Component APIs that are easy to use and hard to use wrongly
A component’s API is what you can pass to it and get back from it. For a React component, that is mostly its props. Many teams learn each API, so small choices matter. Here are six rules, each linked to the part that taught it.
Rule 1: the same names everywhere
If Tabs takes value and Accordion takes selected, every user must remember both. Pick one name for one idea, and use it in every component.
| Idea | A good name | Where we met it |
|---|---|---|
| what is chosen now, set by the parent | value |
Part 28 |
| the first value, then the component keeps it | defaultValue |
Part 28 |
| “the value changed” | onValueChange |
Part 38, Part 39 |
| can’t be used right now | disabled |
Part 46 |
value and defaultValue are React’s own names, from <input>. onValueChange is Radix UI’s name. Part 38 chose it over onChange, so it isn’t mixed up with the browser’s change event on an <input>.
Rule 2: work controlled and uncontrolled
Part 38’s accordion and Part 39’s tabs worked both ways: the component keeps its own state, or the parent does. A design system writes that code once, as a hook, and every component uses it:
import { useState } from 'react'
export function useControllableState<T>(value: T | undefined, defaultValue: T, onValueChange?: (next: T) => void) {
const [own, setOwn] = useState(defaultValue)
const isControlled = value !== undefined
const current = isControlled ? value : own
function set(next: T) {
if (Object.is(next, current)) return
if (!isControlled) setOwn(next)
onValueChange?.(next)
}
return [current, set] as const
}
Object.is checks if two values are the same. If nothing changed, set does nothing, so onValueChange isn’t called for a change that didn’t happen.
Radix UI has a hook with the same name in its own code, in the package @radix-ui/react-use-controllable-state. It also calls its change function only when the value changes. And in development, it warns when a component switches between controlled and uncontrolled.
Rule 3: pass the rest on, and merge className and style
A team will always need one small change, like a class for spacing or an aria-describedby. If Button drops unknown props, the team copies Button and changes the copy.
So a good Button takes every prop a <button> takes. It keeps the ones it needs and passes the rest on with {...rest}. For className and style, it joins its own value with the caller’s, so neither one is lost.
import { useRef, type ComponentProps, type CSSProperties } from 'react'
type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'quiet' }
const base: CSSProperties = { padding: '8px 16px', borderRadius: 6 }
function Button({ variant = 'primary', className, style, type = 'button', ...rest }: ButtonProps) {
const classes = ['ds-button', `ds-button--${variant}`, className].filter(Boolean).join(' ')
return <button type={type} className={classes} style={{ ...base, ...style }} {...rest} />
}
export default function App() {
const saveRef = useRef<HTMLButtonElement>(null)
return (
<form onSubmit={(e) => { e.preventDefault(); console.log('The form was sent') }}>
<Button ref={saveRef} variant="quiet" className="wide" style={{ marginTop: 8 }}
aria-describedby="save-hint" onClick={() => console.log('Save was clicked')}>
Save
</Button>
<p id="save-hint">Saves your draft.</p>
<Button onClick={() => saveRef.current?.focus()}>Focus Save</Button>
</form>
)
}
Save was clicked
Run it and press “Save”. Only “Save was clicked” appears. Three rules are at work here:
- Rest props.
aria-describedbyandonClickaren’t named anywhere inButton. They reach the<button>through{...rest}. - Merging. The class is
ds-button ds-button--quiet wide: the system’s classes, then the caller’s. The style has the system’s padding and the caller’smarginTop. refas a prop. Part 14 showed that in React 19,refis a normal prop. It travels inrestlike any other. Press “Focus Save”, and the focus moves to Save. We checked:saveRef.currentwas the<button>tag.
This Button has no onClick or ref of its own, so the caller’s pass through. With its own, it would need to run both handlers and fill both refs. Part 43’s merging rules did that.
There is one safe default here too: type = 'button'. MDN says a button inside a form is a submit button “if the attribute is not specified”. We tried it in jsdom, a pretend browser. With no type, clicking Save printed “Save was clicked” and then “The form was sent”. With type = 'button', only the first line. A team that wants a submit button can still pass type="submit".
Rule 4: accessible by default
A design system can build Parts 46 to 48 into its API. A button with only an icon has no words, but a screen reader needs a name. So the system’s IconButton makes label a required prop:
import type { ComponentProps } from 'react'
type IconButtonProps = Omit<ComponentProps<'button'>, 'children'> & {
icon: string
label: string
}
function IconButton({ icon, label, type = 'button', ...rest }: IconButtonProps) {
return (
<button type={type} aria-label={label} {...rest}>
<span aria-hidden="true">{icon}</span>
</button>
)
}
const close = <IconButton icon="✕" />
TypeScript stops here: “Property ‘label’ is missing”. The team can’t forget the name. The icon gets aria-hidden="true", so label is the only name (Part 46 showed how aria-hidden works).
Types can’t catch everything. label="" passes the type check and still gives an empty name. That’s why the system also runs the tests from Part 50.
Rule 5: small parts, not more props
Part 38 showed an accordion with 12 boolean props that still couldn’t do everything. In a design system, fifty teams send requests, so that list grows even faster. Give teams small parts that fit together, and let them write what goes between. But Part 38 also listed when plain props win, and Part 45 compared slots with compound components. A design system usually has both kinds.
Rule 6: one look, many tags
A team needs a link that looks like a button. Without help, they copy the button’s classes onto an <a>. Now there are two buttons. Part 43 showed three ways to share one look across tags: two components, an as prop, or asChild. A design system picks one way and uses it everywhere.
Changing the system without breaking apps
A design system changes all the time. But fifty apps depend on it, and a change that breaks one of them costs the system their trust.
Version numbers that mean something
A design system ships as a package with a version number, like 2.4.1. Semantic Versioning, or semver, gives each number a job. Raise the:
- MAJOR (first) number “when you make incompatible API changes”. Incompatible means some old code breaks.
- MINOR (middle) number when you add something new, and old code still works.
- PATCH (last) number when you fix a bug, and old code still works.
Here is how that works for a Button:
| Change | Old code still works? | New version |
|---|---|---|
| Fix the focus ring color | yes | 2.4.1 → 2.4.2 (patch) |
Add size="large" |
yes | 2.4.2 → 2.5.0 (minor) |
Mark kind as deprecated, add variant |
yes, with a warning | 2.5.0 → 2.6.0 (minor) |
Remove kind |
no | 2.6.0 → 3.0.0 (major) |
Deprecated means “still works, but planned for removal” (Part 14). Semver says marking something deprecated MUST raise the minor number. Remove it in a later major version, with “at least one minor release” in between. A deprecation is this whole step.
Semver is a promise the system’s team makes, and no tool checks it. By default, npm saves a package’s version with a ^ in front, like ^2.4.1. The npm rules say ^1.2.3 means at least 1.2.3 but below 2.0.0. So an app with ^2.4.1 takes 2.6.0 the next time it updates. If 2.6.0 breaks something, the number gave no warning.
Primer writes the promise into its docs. A breaking change to one of its “Ready” components means a new major version. Primer will also show teams how to move.
What counts as breaking
A change is breaking when code that worked before stops working, or works differently. For a design system, that’s more than removing a prop:
- Removing a prop, or changing its name. We gave the Rule 3
Buttonthe old propkind="quiet". React putkind="quiet"on the<button>tag, printed no warning, and the button lost its quiet class. Apps in TypeScript get a type error. Apps in JavaScript get no message at all. - Changing a default. Say
Buttonhad notypebefore, and a new version addstype = 'button'. EveryButtonthat used to send its form now doesn’t. We saw both sides in jsdom, in Rule 3. - Making an optional prop required. Say
labelonIconButtonused to be optional. Making it required breaks the type check of every app that left it out. That’s a major version, even though it’s an accessibility fix. - Changing a token’s name. Apps use tokens in their own CSS too. We changed the name
--color-brandand kept a style that still asked for the old name. In Chromium, the background becamergba(0, 0, 0, 0): no color at all. The token format has a$deprecatedproperty, to mark a token as deprecated before it’s removed. - Changing tags or class names. Apps may style or test them. GOV.UK warns that CSS aimed at its classes “may break if you install an update”.
A warning, once, in development only
Primer’s docs say that for a deprecated component, “a warning is shown to the consumer”, the team that uses it. A simple way is a console message, only in development.
Read this. How many warnings do you think the console will show?
import { useEffect, type ReactNode } from 'react'
// In a real package, use process.env.NODE_ENV !== 'production' here (see below).
const DEV = true
const warned = new Set<string>()
function warnOnce(key: string, message: string) {
if (!DEV || warned.has(key)) return
warned.add(key)
console.warn(message)
}
type ButtonProps = {
variant?: 'primary' | 'quiet'
/** @deprecated Use `variant` instead. */
kind?: 'primary' | 'quiet'
children: ReactNode
}
function Button({ variant, kind, children }: ButtonProps) {
useEffect(() => {
if (kind !== undefined) {
warnOnce('Button.kind', 'Button: the "kind" prop is deprecated. Use "variant" instead. It will be removed in version 3.0.0.')
}
}, [kind])
const look = variant ?? kind ?? 'primary'
return <button type="button" className={`ds-button ds-button--${look}`}>{children}</button>
}
export default function App() {
return (
<div>
<Button kind="quiet">Old prop</Button>
<Button kind="quiet">Old prop again</Button>
<Button variant="quiet">New prop</Button>
</div>
)
}
Press Run. The console shows one warning:
Button: the "kind" prop is deprecated. Use "variant" instead. It will be removed in version 3.0.0.
Two buttons use kind, and Strict Mode runs each Effect’s setup twice when the page loads (Part 11). So our lab counted 4 calls to warnOnce. Only the first printed, because warned remembers the key. Without that check, we got 4 warnings, and 200 for 100 buttons. warned is a Set, a list where each value appears only once. It lives outside the component, so every Button shares it.
Why an Effect? Components must be pure, and printing is a side effect (Part 11).
The old prop still works: “Old prop” has the class ds-button--quiet. That’s why this is a minor change.
The /** @deprecated ... */ line is a JSDoc comment, a note for tools. TypeScript’s docs say it is “surfaced in completion lists”, and editors like VS Code cross out kind.
DEV must be true while the app’s team develops, and false for real users. In a design system package, write:
const DEV = process.env.NODE_ENV !== 'production'
Why not Vite’s import.meta.env.DEV? Vite’s docs say a library build replaces every import.meta.env value, but not process.env. So import.meta.env.DEV is fixed when the design system is built, not when the app is. We tried it with Vite 8.3.3, in a small package with both checks. The import.meta.env.DEV warning was gone from the built package. The process.env.NODE_ENV warning showed in the app’s dev server, and was gone from its production build. In an app’s own code, import.meta.env.DEV is fine. The playground can’t read process.env, so the example sets DEV itself.
Codemods, and a changelog
A codemod is a program that changes code, like kind= to variant= in every file. Part 56 ran React’s own, and showed why you still read every change and run your tests. Ship one with the deprecation, so apps can move before the major version. React did the same: Part 56 quoted that React 18.3 is “identical to 18.2 but adds warnings for deprecated APIs”.
A changelog lists the “changes for each version of a project”, in the words of the site Keep a Changelog. Its first rule: “Changelogs are for humans, not machines.” It uses headings like Added, Deprecated and Removed.
## [2.6.0] - 2026-10-07
### Added
- Button: the `variant` prop, with the values `primary` and `quiet`.
### Deprecated
- Button: the `kind` prop. Use `variant`. A codemod changes it for you.
`kind` will be removed in 3.0.0.
Deciding together
The code is the easy part. The hard part is people.
Who decides
GOV.UK has a Design System team that owns its system. We’ll call it the core team. Other teams can contribute, which means they help build it. GOV.UK writes the steps down in public:
- Anyone can suggest a component in an issue, a page on GitHub for one idea or problem. Show “evidence of the user needs”, not code.
- To build it, meet the core team and agree what the work covers.
- Test it with a “representative sample of users, including those with disabilities”, as the contribution criteria ask.
- The core team reviews the work against fixed criteria, and votes on it.
Other systems mark new work clearly. Carbon has Carbon Labs, for work that “has not yet met the definition of done criteria”. Primer marks each component Experimental, Ready or Deprecated. GOV.UK uses Trial and Stable. A trial component becomes stable after 6 months with no negative feedback.
These labels tell teams how much of the version promise applies. Primer’s major-version promise is for its Ready components. GOV.UK says a trial component may “change substantially”.
A new component, or a new variant?
A variant is another look of the same component, picked with a prop. A team asks for a “Danger button”, for deleting things. Is that a new component, or a variant="danger" on Button?
GOV.UK’s first criteria help. A new proposal must be:
- Useful: “There is evidence that this component or pattern would be useful for many teams or services.”
- Unique: it doesn’t copy something already in the Design System.
A danger button does a button’s job, with the same tag and keys. Only the look changes. So it isn’t unique: it’s a variant. A date picker has a different job, tags and keys, so it’s a new component. A simple test follows:
- Same job, same HTML, same keyboard behavior: a variant or a prop.
- A different job, or a different role for a screen reader: a new component.
- Needed by one screen in one app: neither, yet. Build it in the app, with the system’s tokens. Suggest it for the system when other teams need it too.
When a team needs to change a part
GOV.UK says that changing its components can “create potential risk”. Its advice, in short:
- Don’t write CSS that targets the system’s classes. For a small change, add your own extra class next to theirs.
- Start your own class names with your own word, like
app-. GOV.UK’s all start withgovuk-, so the two never mix up. - For a large change, copy the component, rename it, and own it. Then “you will not receive any future updates from the original component”.
The className merging from Rule 3 is the React version of that extra class.
Checks before every release
Every change can reach every app, so the system runs checks before each release, token changes included.
- Component tests. Part 49 wrote tests that click and type like a user, with React Testing Library.
- Accessibility tests. Part 50 used four layers: a linter, role queries, axe-core in real browsers, and a person.
- Visual regression tests. A regression is something that worked before and broke. These tests take a picture of each component and compare it with the last good picture. Playwright, from Part 50, has one:
toHaveScreenshot(). The first run has no picture to compare, so it fails with “A snapshot doesn’t exist” and saves one. Its docs also warn that pictures can differ between computers and browsers. So take them in the same setup every time. - Storybook. A tool for showing each component’s examples on its own. Its docs call it a “workshop that lives alongside your app”. Its visual tests run on a web service made by the Storybook team.
A failing check stops the release.
Common mistakes
A prop for one screen
type ButtonProps = {
variant?: 'primary' | 'quiet'
extraMarginForTheSalePage?: boolean
}
One team needs more space on one page, so the system adds a prop. Next month, another team wants a different space. Soon nobody knows which of these props are still used.
The fix: say no to props for a single screen. The team passes className or style, which Button already merges (Rule 3). If many teams ask for the same thing, it becomes a real prop, with a general name, like size.
A breaking change in a minor version
The system changes a prop’s name, adds a default, or changes a token’s name, and ships 2.6.0. Apps with ^2.4.1 take it on their next update. Every change in What counts as breaking belongs in a major version.
The fix: keep the old way working, warn once in development, and ship a codemod. Remove the old way only in 3.0.0.
Practice
Press Edit on any example above and try these.
- In “Try this first”, add a
radiustoken with the value6px. Use it as theborder-radiusof.ds-button. - In the
warnOnceexample, delete|| warned.has(key). How many warnings do you see now? Why that number? - In the same example, set
DEVtofalse. What does the console show? Is “Old prop” still quiet? - In the Rule 3
Button, changetype = 'button', ...resttotype, ...rest. Press “Save”. What does the console show, and why?
Answers
- Add a new object
const shape = { radius: '6px' }. Add${vars('shape', shape)}to both theme blocks. Then addborder-radius: var(--shape-radius);to.ds-button. Both buttons get round corners, in both themes. - 4 warnings. Two buttons use
kind. Strict Mode runs each Effect’s setup twice when the page loads. 2 × 2 = 4. Thewarned.add(key)line still runs, but nothing checks it now. - The console shows nothing.
warnOncereturns before it prints. “Old prop” still has the classds-button--quiet, becausevariant ?? kindstill readskind. That is how it behaves for real users. - “Save was clicked”, then “The form was sent”. With no default, the
<button>has notypeattribute. Inside a form, that makes it a submit button. That’s why adding the default would be a breaking change.
Interview questions
Try to answer each one out loud before you open the answer.
What is a design system? How is it more than a component library?
A component library is code. A design system adds three things. Tokens are the basic choices as named values. Docs say when to use each part. Rules say how it changes and who decides.
A strong answer says it serves both the teams that build apps and the people who use them. Without rules, teams copy and change parts, and the system splits apart.
What are design tokens, and why keep them as data?
A token is a design choice with a name, like color-brand for #0f766e. Components use the name, never the raw value. Kept as data, one file can become CSS and code for other platforms.
A strong candidate adds that a token can point to another token, as Primer’s functional tokens point to base tokens. Then a dark theme only changes where they point. Tokens also have versions, like code: a new name breaks apps that use the old one.
What makes a component API hard to use wrongly?
The same names everywhere, like value, defaultValue and onValueChange. Controlled and uncontrolled use. Unknown props and ref passed on, with className and style merged. Required props where accessibility needs them, like label. Safe defaults, like type="button".
A strong candidate adds composition: small parts that fit together, not a new prop for every request.
Which changes to a design system are breaking?
Any change after which working code stops working, or works differently. Removing a prop or changing its name. Changing a default, like adding type="button". Making an optional prop required. Changing a token’s name. Changing tags or class names that apps style or test.
A strong candidate says all of these need a major version. A ^ range takes new minor versions on its own.
How do you remove a prop without breaking fifty apps?
First, a minor version. Add the new prop and keep the old one working. Mark it @deprecated, and warn once in development. Ship a codemod and a changelog entry. Then remove it in the next major version.
A strong candidate uses process.env.NODE_ENV for the warning in a package, since a library build fixes import.meta.env.DEV too early. They also check who still uses the old prop before the major version.
Fifty teams use your design system, and there are already three different buttons. How do you stop it from happening again?
Make the system the easy path. Give Button a flexible API, so teams don’t need to copy it. Write down how to suggest a change, with clear rules and a fast review. Use semver, warnings, codemods and a changelog for every change.
The other two buttons live in the teams’ own apps. You can’t deprecate code you don’t own. So add variants to the system’s Button for what those buttons really do. Then help the teams move: a codemod, a short guide, and time. Track how many teams have moved, and talk to the ones who haven’t.
A strong candidate says the work is mostly about people. A slow review makes teams build their own parts.
Sources
- Semantic Versioning 2.0.0: MAJOR, MINOR and PATCH, the rule that a deprecation raises the minor version, and the FAQ on how to deprecate.
- npm: About semantic versioning (ranges like
^1.0.4),save-prefix(default"^"), and node-semver (^1.2.3 := >=1.2.3 <2.0.0-0). - Keep a Changelog 1.1.0: what a changelog is, “Changelogs are for humans, not machines”, and the six kinds of change.
- Design Tokens Format Module 2025.10, Design Tokens Community Group final report (not a W3C standard): what a token is, aliases,
$deprecated, JSON files, and the “single source of truth”. - Primer, GitHub’s design system: Color usage (base and functional tokens) and Component status (Experimental, Ready and Deprecated, the major version for breaking changes, and the warning for deprecated components).
- Using CSS custom properties, MDN:
--name,var(), and that custom properties inherit. - prefers-color-scheme, color-scheme and
matchMedia, MDN. <button>, MDN:submitis the defaulttypeinside a form.- GOV.UK Design System: Contribution criteria (useful, unique, usable, consistent, versatile), Propose a component or pattern, Develop a component or pattern, Component lifecycle statuses and Extending and modifying components in production.
- Contributing to Carbon, IBM: Carbon Labs for new ideas.
- Building for production: library mode, Vite:
import.meta.env.*is replaced when a library is built,process.env.*is not. Env variables and modes, Vite:import.meta.env.DEV. - React v19, react.dev:
refas a prop, and the plan to deprecateforwardRef. - Components and Hooks must be pure, react.dev: why the warning goes in an Effect.
- StrictMode, react.dev: the extra setup and cleanup of Effects in development.
- JSDoc reference, TypeScript:
@deprecated. - Radix UI’s
@radix-ui/react-use-controllable-state1.2.6 code on npm: theuseControllableStatehook, the change function called only on a change, and the warning in development. - Visual comparisons, Playwright:
toHaveScreenshot(), the first-run message, and why pictures differ between setups. Why Storybook? and Visual tests, Storybook. - The warning counts, the class and style results, the form results, the
kindresult and the ref check come from running React 19.3.0 in jsdom for this post. The theme colors, the badge, the renamed token and thematchMediaresults come from Chromium 151. The package results come from Vite 8.3.3. - This part follows the Design-System Governance at Scale kata in react-katas. The kata is an interview question about people and process. We kept its main ideas: semver, deprecation, codemods, a review process, ways for teams to change parts safely, and tracking who still uses old parts. We left out its numbers, like a set time for deprecations, because we couldn’t find a source for them.