Design Tokens First: CSS That Survives a Redesign
The difference between a stylesheet that survives three years and one that collapses in six months is not the framework. It is whether the values live in one place.
Declare the vocabulary once
:root{
--bg:#0a0d12;
--surface:#121922;
--line:#1f2a38;
--text:#e8eef4;
--text-dim:#94a3b4;
--accent:#c8ff2e;
--radius:18px;
--space-3:16px;
--font-display:'Bricolage Grotesque',sans-serif;
--font-body:'DM Sans',sans-serif;
}
Now a rebrand is an edit to ten lines, and a dark mode is a second block of the same ten. Nothing else in the codebase has to know that the accent colour changed.
Rules that keep the vocabulary honest
- No raw hex outside the token block. If you need a new shade, name it.
#c8ff2eappearing in nine files is nine future mistakes. - Name by role, not by appearance.
--accentsurvives a change to blue;--greendoes not. - Spacing on a scale. A four-step scale prevents the slow drift into seventeen slightly different paddings.
- One radius scale. Buttons, cards and inputs should agree with each other without being individually tuned.
Dark mode is a second token block, not a second stylesheet
Because every colour has a name, dark mode becomes a redefinition of those names and nothing else. There are two mechanisms worth wiring up: the system preference, which costs you nothing, and an explicit user choice, which you store and apply as an attribute on the root element.
:root{ --bg:#ffffff; --text:#12161c; --surface:#f6f7f9; }
@media (prefers-color-scheme: dark){
:root{ --bg:#0a0d12; --text:#e8eef4; --surface:#121922; }
}
:root[data-theme="dark"]{ --bg:#0a0d12; --text:#e8eef4; --surface:#121922; }
Keep the attribute selector last in the cascade so an explicit choice wins in both directions, and remember that dark mode is not an inversion. Pure white text on pure black vibrates and is tiring to read: dark surfaces sit around #0a0d12 to #14181f, and body text stays slightly below #ffffff.
Naming scales that stop drift
Three scales cover nearly every layout decision: space, radius and type size. A four- to eight-step numeric scale is enough, and --space-3 stays meaningful when the card it pads is redesigned.
:root{
--space-1:4px; --space-2:8px; --space-3:16px;
--space-4:24px; --space-5:32px; --space-6:48px;
--radius-sm:6px; --radius-md:12px; --radius-lg:18px;
--step--1:0.875rem; --step-0:1rem; --step-2:1.5rem;
}
Where a component needs its own knob, point it at the scale instead of copying the number: --card-gap:var(--space-3). That keeps one decision in one place, and the component still reads as intentional rather than accidental.
Contrast is a hard constraint, not a taste decision
The moment you repoint --accent from lime to a pale blue, every place that used it as text can fall below the 4.5:1 contrast ratio WCAG requires for body text (3:1 for large text and for interface borders). The token made the rebrand cheap; the audit is what stops it becoming a regression.
Two habits prevent most of it. Never use an accent as a text colour on a light surface unless you have measured it, and check the focus ring and the disabled state — the two tokens everyone forgets and the two that decide whether the interface is usable from a keyboard. Automated checkers only see the pairs you point them at, so test text against its real background rather than against the token you assumed was behind it. WCAG's contrast minimum is the reference worth bookmarking.
Migrating an existing stylesheet without a rewrite
You do not need a rewrite to adopt tokens. Define the block from the values your CSS already uses — the three greys, the two radii, the one accent — then replace literals file by file, keeping the rendered result identical. Commit each file separately, so any visual regression has exactly one candidate cause.
# how bad is it, before you start
grep -rEo '#[0-9a-fA-F]{3,8}' assets/css | sort | uniq -c | sort -rn | head -20
That histogram is your work list. The values with the highest counts become the first tokens; the twenty colours used once each are usually drift worth deleting rather than naming.
Utility frameworks, and a guard against raw hex
A utility framework does not remove the need for tokens, it changes where they live. Map the utilities onto the same variables rather than a parallel palette, or you end up with two vocabularies that disagree at the worst moment.
@theme {
--color-accent: var(--accent);
--radius-lg: var(--radius-lg);
}
Then make the rule enforceable. One grep in CI turns “no raw hex outside the token file” from a convention into a failed build:
if grep -rInE '#[0-9a-fA-F]{3,8}\b' assets/css --exclude=tokens.css; then
echo "raw colour outside tokens.css"; exit 1
fi
Allow the few legitimate exceptions with an inline comment and a matching exclude, or the first person the check blocks will disable it. Document the token file as the contract for anyone joining: names, their role, and when not to use them. MDN's custom property reference covers the inheritance rule that surprises people most — a custom property inherits, so an override deep in the tree changes every descendant that reads it.
Mobile first, then add
.card-grid{display:grid;gap:20px;grid-template-columns:1fr}
@media(min-width:900px){.card-grid{grid-template-columns:repeat(3,1fr)}}
One breakpoint that adds columns is easier to reason about than three that subtract them, and it means the phone layout — the one most visitors see — is the layout you actually designed.
Two accessibility rules worth more than any checklist
- Never encode meaning in colour alone. A status badge needs the word “Draft”, not only a red dot.
- Respect the system preference. Wrap motion in
@media (prefers-reduced-motion: reduce)and the site stops fighting people who get motion sick.
Ship the tokens as a file, not as a habit
Keep the token block at the top of one stylesheet that every page loads. The moment it is duplicated into a second file, the two copies start to disagree — and the redesign you were trying to make cheap becomes expensive again.
Last updated 19 Sep 2026