Building a Design Token Color System
The primitive/semantic two-tier pattern that survives a rebrand, a dark-mode launch, and six new product surfaces without a hardcoded hex value anywhere.
A palette is a set of colors someone picked. A design token system is what makes that palette usable across a real codebase, multiple themes, and years of feature growth without every component quietly re-inventing its own hardcoded hex value. The difference matters the first time a rebrand or a dark-mode launch requires touching color, and a token system means changing a handful of mapping values instead of grepping the entire codebase for hex strings.
Two tiers, not one flat list
The pattern that scales, used by most mature design systems, splits tokens into two layers with genuinely different jobs:
Primitive tokens hold raw values and describe *what the color literally is*, typically named by hue and a numeric step: --blue-50 through --blue-900, --red-50 through --red-900, and so on. These come directly from a generated ramp — see the shades, tints & tones generator for producing an evenly-stepped set from a single base hue rather than hand-picking each step. Primitives never get referenced directly inside components; they exist purely as the raw palette.
Semantic tokens describe *what role the color plays*, and their values point at primitives rather than holding raw hex directly: --color-action-primary: var(--blue-500), --color-text-error: var(--red-600), --color-surface-warning: var(--amber-100). Components reference semantic tokens exclusively — a button's background is var(--color-action-primary), never var(--blue-500) and never a raw hex value.
The payoff of this split shows up the moment something changes. Rebrand the primary blue to a different hue: update where --color-action-primary points, and every component that used the semantic token updates automatically, with zero component-level changes. Flip to a dark theme: redefine the semantic layer's mappings inside a [data-theme="dark"] scope (same semantic names, different primitive targets, or entirely different primitive values), and again, no component code changes. A flat, single-tier token list (just named colors, no role layer) can't do either of these without a manual find-and-replace across every place a color was used.
Naming conventions that hold up under scrutiny
A few naming decisions save real pain later:
- Numeric steps, not descriptive names, for primitives.
--blue-500over--blue-medium— "medium" is subjective and doesn't sort, whereas a numeric scale (typically 50/100/200...900, following the convention popularized by utility-CSS frameworks) sorts naturally and leaves room to insert intermediate steps later if needed. - Role, not appearance, for semantics.
--color-action-primary, not--color-blue-button— the whole point of the semantic layer is that its *value* can change hue entirely (during a rebrand) without the *name* becoming a lie. A token named after its current color stops making sense the moment that color changes. - State suffixes as their own tokens, not computed inline.
--color-action-primary-hover,--color-action-primary-disabledas explicit semantic tokens (each pointing at its own primitive step) rather than a runtimedarken()/opacity trick applied ad hoc in component code — this keeps every state's actual contrast checkable independently, rather than trusting that "10% darker" happens to still pass 4.5:1 in every case.
Where OKLCH earns its keep in this specific pattern
The primitive ramp is exactly the place OKLCH's perceptual evenness matters most, because it's generated programmatically and used at scale — every component in the product eventually touches some step of it. A ramp built by walking HSL lightness in even numeric steps tends to have visibly uneven "weight" between certain steps (a defect covered in depth in what OKLCH is and why it matters); the same numeric step size in OKLCH produces a ramp that looks evenly spaced to the eye across the whole hue range, which is specifically valuable when the ramp needs to hold up consistently across many unrelated hues (blue, red, green, amber) in the same system rather than just one.
Accessibility has to be baked into the primitive layer, not bolted on later
Decide which primitive steps are contrast-safe against your standard surface colors *before* assigning them to text-bearing semantic roles — don't pick a primitive because its hue looks right and discover afterward that it fails 4.5:1 against your background. This is the same discipline covered in more depth in choosing an accessible color palette: generate the ramp, test every candidate step against real surfaces with the contrast checker, and only then map passing steps onto text-carrying semantic roles like --color-text-error or --color-text-link.
Implementation: CSS custom properties are the natural runtime layer
:root {
/* primitives */
--blue-500: #3b82f6;
--blue-700: #1d4ed8;
--red-600: #dc2626;
/* semantics — light theme default */
--color-action-primary: var(--blue-500);
--color-action-primary-hover: var(--blue-700);
--color-text-error: var(--red-600);
}
[data-theme="dark"] {
/* only the semantic layer needs to change */
--color-action-primary: var(--blue-400, #60a5fa);
--color-text-error: var(--red-400, #f87171);
}The full runtime-switching mechanics — including how to avoid a flash of the wrong theme on load — are covered in CSS variables for theming, and the CSS variable exporter generates a starter primitive file from any palette you've built here, ready to drop into a :root block and layer semantics on top of.
Starting from a curated palette instead of a blank hue wheel
If you'd rather adapt an existing, coherent palette than build primitives from a single hue outward, the 120 curated theme palettes on this site — Corporate Blue, Startup Tech, and Minimalist Mono among them — are reasonable starting points for a primitive layer, each already built around a stated intent rather than an arbitrary hue rotation.
A migration note for teams with an existing flat palette
If your codebase already has color scattered across hardcoded hex values in components, migrating to a two-tier system doesn't require a rewrite done in one sitting. A workable sequence: first, inventory every distinct hex value actually in use (a simple grep across the codebase surfaces this quickly, and the count is often larger and messier than teams expect); second, map each one onto the nearest step of a newly generated primitive ramp using the color name finder's nearest-match logic as a sanity check on how close the mapping actually is; third, introduce semantic tokens pointing at those primitives and swap component references over incrementally, file by file, rather than attempting a single atomic cutover. The intermediate state — some components on raw hex, some on semantic tokens — is messier than the end state, but it's a normal and safe migration path, and it's far lower-risk than a big-bang rewrite across an entire component library at once.