CSS design tokens and class naming are two ways of turning repeated decisions into named ones: a token holds a value in one place, and a class name holds the role of a part in one place. Together they are what keeps a stylesheet maintainable after the third redesign.
What is a design token, in CSS terms?
A design token is a custom property, declared on the root element and read with the var() function. The custom property holds a raw value; the rule that consumes it decides what the value means.
:root {
--ink-900: #0f172a;
--ink-600: #475569;
--paper: #ffffff;
--line: #e2e8f0;
--brand-500: #1d4ed8;
--brand-700: #1e40af;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--radius-sm: 6px;
--radius-md: 10px;
--text-body: 1rem;
--text-lead: 1.125rem;
}
Two things follow from how custom properties work. They inherit, so a token declared on a container is available to every descendant and can be overridden locally without touching the global value. And the value is substituted as text at computed-value time, so a property that references a token whose value is invalid becomes invalid itself, which is why a sensible fallback is worth writing: var(--space-4, 1rem) keeps the rule working even if the token is missing.
Why do you need more than one layer of tokens?
Declaring --brand-500 and then using it directly in a rule mixes two different jobs. The colour is a raw choice, but the role of that colour in the interface is a separate decision that changes far more often, usually by someone other than the person who picked the shade.
The useful arrangement is three layers. The first holds raw primitives, the palette and the spacing scale. The second holds semantic tokens that describe a purpose, such as the colour of body text or the colour of a page background. The third is optional and holds values that only one component needs, kept local to that component rather than declared globally.
:root {
/* primitives */
--slate-900: #0f172a;
--slate-600: #475569;
--paper: #ffffff;
--blue-700: #1e40af;
/* semantic */
--color-text: var(--slate-900);
--color-text-muted: var(--slate-600);
--color-surface: var(--paper);
--color-accent: var(--blue-700);
--color-border: #e2e8f0;
}
With that arrangement, a rule says color: var(--color-text-muted) and never mentions a shade. If the brand colour changes, you edit one line. If the design system decides muted text needs more contrast, you edit one line, and every element that used it follows without a single class being renamed.
How should classes be named?
The naming scheme matters less than the discipline behind it. A workable convention names a block, an optional element inside it, and an optional variant, joined by a double underscore and a double hyphen.
<form class="checkout-form">
<fieldset class="checkout-form__group">
<legend class="checkout-form__legend">Payment details</legend>
<label class="checkout-form__label" for="card-number">Card number</label>
<input class="checkout-form__input" id="card-number" name="card-number"
inputmode="numeric" autocomplete="cc-number">
<p class="checkout-form__hint checkout-form__hint--error">Check the card number</p>
</fieldset>
<button class="button button--primary checkout-form__submit" type="submit">Pay now</button>
</form>
Read the names and you can tell what each element is without looking at the stylesheet. checkout-form is the block, checkout-form__group is a part of it, and checkout-form__hint--error is a variant of that part. The submit button also carries button and button--primary because it shares its appearance with every other primary button on the site, and the two class sets do different jobs.
Two rules keep the scheme from turning into noise. Name by role rather than by appearance, so a class called summary-total survives a restyle while grey-box becomes a lie the first time it is repainted. And avoid page-specific names, because the moment the same component appears on a second page you are back to copying rules.
How do tokens make a dark colour scheme cheap?
Because the semantic tokens are the only ones that rules reference, a colour scheme is a matter of reassigning those semantic tokens and leaving every rule untouched.
:root {
color-scheme: light dark;
--color-text: light-dark(#0f172a, #e2e8f0);
--color-text-muted: light-dark(#475569, #94a3b8);
--color-surface: light-dark(#ffffff, #111827);
--color-border: light-dark(#e2e8f0, #374151);
}
The light-dark() function resolves to the first value when the used colour scheme is light and the second when it is dark, so a single token carries both palettes. The color-scheme declaration is what decides which one is used, and it also tells the browser which theme to give native interface elements such as form controls and scrollbars. The function is supported in current evergreen browsers; where you need to support an older engine, the equivalent is a prefers-color-scheme media query that reassigns the same semantic tokens, because the rules that consume them do not change either way.
@media (prefers-color-scheme: dark) {
:root {
--color-text: #e2e8f0;
--color-text-muted: #94a3b8;
--color-surface: #111827;
--color-border: #374151;
}
}
A manual toggle that overrides the operating system setting is then a single extra rule that sets color-scheme: dark on the root element, and every token follows. Either way the important property holds: no component rule names a colour, so the whole palette can be replaced without touching a component.
A small token set worth copying
| Token | Typical value | Role |
|---|---|---|
| --color-text | #0f172a | Default body text |
| --color-text-muted | #475569 | Secondary text and captions |
| --color-surface | #ffffff | Card and page background |
| --color-accent | #1e40af | Links, primary buttons, focus rings |
| --color-border | #e2e8f0 | Card edges and dividers |
| --space-4 | 1rem | The standard gap between blocks |
| --radius-md | 10px | Corner rounding on cards and inputs |
| --text-lead | 1.125rem | Introductory paragraph size |
Core ideas in one place
- Custom properties hold values; they inherit, so they can be overridden at any level.
- Keep primitives and semantics apart, and let rules reference only the semantic layer.
- Give every
var()a fallback if the token might ever be missing. - Name class blocks by role, elements by their part, and variants with a double hyphen.
- Let a component carry two class sets when it shares appearance with something else.
- A colour scheme change should touch only the semantic token block.
Try it yourself: take the checkout form above and build a token file for it: eight colour tokens, six spacing steps and two radii. Write the component rules using only the semantic tokens, then switch the dark block on and confirm every part of the form changes together. Finally, rename the checkout-form__hint--error class to checkout-form__hint--critical and check that only the rules that mention it need editing.