Use this when you’re teaching the conventions of a system: the named colour and spacing tokens, how the names are built, and the right vs wrong way to use them.
This is a copyable exemplar. Lift the demo <section>s below into a lesson built from assets/lesson-template.html — keep the design tokens and the warm-neutral palette verbatim.
A design system is a small shared dictionary for a product’s look. Instead of writing a raw colour like #D97757 in fifty places, you give it one agreed name — --clay — and everyone uses that name. Change the name’s value once and every button, link, and badge updates together.
This page teaches the three habits a design system lives or dies by: the swatches (what the named values actually are), the naming table (how a token’s name is built so anyone can guess the right one), and the do / don’t pairs (the small mistakes that quietly break the system).
Think of it like… a kitchen with labelled jars. Nobody scoops from an unmarked bag of white powder hoping it’s sugar — they reach for the jar that says “Sugar.” Tokens are the labels; this page is the rule that says always reach for the jar, never the raw bag.
Tokens here are CSS custom properties declared once on :root and consumed with var(--token). Because cascade resolution is live, overriding a token on a scope (a theme class, prefers-color-scheme, a component root) re-themes every descendant with zero markup changes — the single source of truth is the declaration, not the usage site.
Two layers are worth separating in a mature system: primitive tokens (raw scale values like --clay: #D97757) and semantic tokens that alias them by role (--btn-bg: var(--clay)). Components consume only semantic tokens, so re-skinning means re-pointing the alias, never touching the component.
Every named token, shown as a swatch with its real value. This is the page a designer or developer scans to find “the right orange” instead of guessing a hex code.
The spacing tokens follow a fixed step scale (a 4px base: 4 · 8 · 12 · 16 · 24 · 52). Constraining spacing to a scale removes the “is it 13px or 14px?” bikeshed and guarantees vertical rhythm — every gap in the product is a multiple you can reason about. Colours are similarly closed: ten names, no off-roster hexes.
All of these live in one :root block at the top of the stylesheet, e.g. --clay:#D97757; and --space-4:16px;. A component never restates a hex — it writes color:var(--clay) or padding:var(--space-3) var(--space-4).
A good token name reads like a sentence: category, then role, then state. Once you know the shape, you can guess a token you’ve never seen.
btn, role bg, state hover → resolves to a primitive token.The contract in one table. Each row is a semantic token: its name, the primitive it points to, and exactly where to reach for it.
| Token | Value | Use it for |
|---|---|---|
| --btn-bg category · role |
var(--clay) |
Fill of a primary button — the one main action on a screen. |
| --btn-bg-hover category · role · state |
var(--clay-d) |
Primary button background while the pointer is over it. |
| --btn-fg category · role |
#FFFFFF |
Label text on a filled primary button. |
| --btn-bg-success category · role · intent |
var(--olive) |
Confirm / save actions where green signals “safe to proceed”. |
| --btn-bg-danger category · role · intent |
var(--rust) |
Destructive actions only — delete, remove, cancel-for-real. |
| --space-3 scale step |
12px |
Vertical padding inside a control. Pairs with --space-4 horizontally. |
| --radius-row category · role |
9px |
Corner radius on buttons, inputs, chips — anything row-sized. |
Notice every --btn-* token aliases a primitive (--clay, --olive…). Components reference --btn-bg, never --clay directly. That indirection is what lets you ship a “high-contrast” or “dark” theme: you re-point the seven --btn-* aliases on a scope and the markup is untouched.
/* primitive scale */ --clay: #D97757; --clay-d: #B85C3E; /* semantic aliases — components use THESE */ --btn-bg: var(--clay); --btn-bg-hover: var(--clay-d); --btn-fg: #FFFFFF;
The same button, two ways. The left column keeps the system intact; the right column quietly breaks it.
Use the named token. If the brand orange ever changes, this button changes with it — for free.
.btn-primary {
background: var(--btn-bg);
color: var(--btn-fg);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-row);
}
A raw hex and magic numbers. Invisible to a theme change, and the next person can’t tell which orange this is meant to be.
.btn-primary {
background: #D97757; /* which orange? */
color: #fff;
padding: 11px 15px; /* off-scale */
border-radius: 8px; /* not 9px */
}
The danger token says what it’s for. Anyone reading the code knows this button deletes something.
/* delete confirmation */ .btn-delete { background: var(--btn-bg-danger); }
Named after the colour, not the job. Rename the brand red and the token --red-button becomes a lie.
/* what is this for? */ .btn-delete { background: var(--red-button); }
Pick an intent and a size below. The preview button is styled only from tokens — the code readout updates to show the exact tokens in play.
Intent
Size
.btn { }
The button carries no per-intent CSS class. Each segmented control writes the semantic tokens (--btn-bg, --btn-px, …) onto the element’s inline style as custom properties. The single .ds-btn rule reads them with var(). That is exactly how a real token-driven component behaves: swap the token values, the component re-renders, no new selectors.