// color_theory · palette_token_system
A Color Palette Is a Token System, Not a Row of Swatches
Most color palettes start as a handful of brand hex codes and grow by accretion — a slightly lighter blue added for a hover state here, a barely-different gray added for a border there — until no one can say with confidence which of forty near-identical blues is actually load-bearing. A palette built as a token system instead starts from a small set of perceptually even tonal ramps, gives every step a role instead of a name, and lets light and dark themes swap by reassigning aliases rather than redrawing the palette from scratch.
§ 01
Why "Blue-500" Isn't a Palette
Ask most teams how many blues are in their product and the honest answer is usually "we're not sure" — a brand blue picked once by a designer, a slightly lighter one added later for a hover state, a slightly darker one added for a pressed state, a muted one for disabled controls, and a translucent one for overlays, each added one-off as the need came up rather than generated by any shared logic. None of these are wrong in isolation. Collectively, they're a pile of near-duplicate colors nobody can reason about, because nothing about the palette explains why step four is exactly that shade and not the one a designer picked last Tuesday.
A color system that can survive a rebrand, a new dark theme, or a new brand color needs two genuinely separate concerns stacked on top of each other, plus a third layer that resolves them per theme:
Tonal ramp — how the actual color steps get generated for a given hue: an ordered, perceptually even scale from near-white to near-black, produced by a formula rather than picked by eye one step at a time.
Semantic role — which UI need a given ramp step is standing in for: a background, a border, a body of text, a danger state — named for what it does, not for what it looks like today.
Theme resolution — which ramp step a given semantic role points to right now, which is the only thing that should change between a light theme and a dark one.
The rest of this guide is that stack, worked through in order — starting with the part almost every hand-picked palette gets subtly wrong before a single semantic name is even chosen: the math behind the ramp itself.
§ 02
HSL Lies About Lightness
hsl() feels like the right tool for building a ramp —
hold hue and saturation constant, sweep lightness from high to
low, done. The problem is that HSL's L channel is a
purely mathematical average of the RGB channels, not a measurement
of how bright a color actually looks to a human eye. Two colors
can share an identical L value in HSL and read as
wildly different brightnesses on screen —
hsl(60, 100%, 50%), a pure yellow, and
hsl(240, 100%, 50%), a pure blue, both sit at
L: 50%, yet the yellow looks dramatically lighter
than the blue at a glance. A ramp built by holding HSL's
L constant across hues, or stepping it evenly for a
single hue, inherits that mismatch — steps that measure as "the
same lightness" on paper but don't read as evenly spaced to an
actual viewer.
oklch(), part of the CSS Color Module Level 4
specification, fixes this by construction. It's the cylindrical
form of the Oklab color model, purpose-built so its
L channel tracks perceived lightness rather than a
channel average — holding L constant while sweeping
hue produces colors that actually look similarly light, something
HSL cannot promise:
oklch(L C H) → L 0–1 (0 = black, 1 = white) · C 0–≈0.4 in sRGB · H in degrees, cyclical
L is lightness on that perceptual scale.
C is chroma — roughly "how much color," unitless,
typically staying under about 0.4 for colors that render in-gamut
on a standard sRGB display, though wider-gamut screens (Display
P3, Rec. 2020) can push it higher. H is hue in
degrees around a color wheel, same intuition as HSL's hue channel,
wrapping back to 0 at 360.
hsl() and
oklch() values in a modern browser. Chroma still
interacts with perceived brightness at the margins, so the OKLCH
row isn't perfectly identical — just markedly closer than HSL's,
which isn't trying to be perceptual at all.
One consequence worth planning around: in-gamut chroma isn't
independent of lightness. A color can't be both extremely light
(or extremely dark) and highly saturated — the maximum
displayable C shrinks toward zero as
L approaches 0 or 1. A ramp that holds
C fixed across every step, for simplicity, will get
its lightest and darkest steps silently clipped to whatever the
display can actually show — which is why the next section tapers
chroma toward the ends of the ramp rather than holding it flat.
§ 03
Building a Tonal Ramp
A ramp starts from one fixed hue and produces an ordered set of
lightness steps for it — commonly ten or eleven, numbered the way
Tailwind popularized (50 through 900 or
950, low numbers light, high numbers dark) though the
exact count and labels are a convention, not a rule. Three things
vary together as the ramp descends from light to dark:
| Step | Target L | Typical use |
|---|---|---|
| 50 | ≈ 97% | Tinted page/section backgrounds |
| 100–200 | ≈ 90–82% | Subtle fills, hover backgrounds |
| 300–400 | ≈ 72–62% | Borders, disabled fills, icons |
| 500 | ≈ 55% | Base brand color, primary actions |
| 600–700 | ≈ 46–36% | Hover/active states on the base |
| 800–900 | ≈ 27–20% | High-emphasis text, dark surfaces |
| 950 | ≈ 14% | Near-black text, dark-mode base |
Hue stays fixed for the whole ramp in the simplest version of this model. Some mature systems allow it a small, deliberate rotation across the steps — a few degrees warmer at the dark end, say — to compensate for a hue's perceived shift at extreme lightness, but that's a refinement worth adding once the base ramp works, not a requirement to get a usable palette shipped.
Lightness is stepped more densely through the middle of the range than at the extremes, because that's where most real UI usage clusters — base brand colors, text, and interactive states all live in the 300–700 band, while 50 and 950 exist mainly as backgrounds and are visited far less often.
Chroma peaks somewhere in the middle of the ramp
and tapers toward both ends, following the gamut constraint from
the previous section — a flat C
across all eleven steps is the single most common reason a
hand-rolled ramp looks uneven at the tips even when the lightness
math is right.
None of these exact percentages are a law — they're a workable starting point, not a spec. What matters is generating the steps from a small, explicit rule for a given hue instead of eyeballing eleven separate colors and hoping they stay consistent the next time someone has to add a twelfth.
§ 04
Primitive, Semantic, Component
A ramp is raw material, not a usable token set on its own — it needs to sit underneath the same three-tier structure that governs any well-run token system, applied specifically to color:
Primitive — the ramp's own output, named after
itself: color.blue.500, color.gray.100.
Purely descriptive. A primitive token carries no opinion about
where it gets used.
Semantic — a role, referencing exactly one
primitive step: color.background.brand pointing at
color.blue.500. This is the tier where meaning
actually lives, and the only tier a designer or engineer should
need to think in day to day.
Component — a semantic token scoped to one
component's specific property, so that component can be retargeted
without touching the shared semantic layer:
button.primary.background pointing at
color.background.brand.
The rule that keeps this from collapsing back into chaos is simple: each tier only ever references the tier directly below it. A component token pointing straight at a primitive, skipping semantic entirely, is the single most common way a three-tier system quietly turns back into a one-tier system — it works fine until the semantic layer gets retuned and that one component doesn't move with it.
§ 05
Naming Roles, Not Appearances
A semantic color token's name is a promise about function, not a
description of its current pixel value.
color.text.error sounds reasonable until the brand's
error color moves from red to amber for accessibility reasons, and
now every consumer of that name is technically correct and
visually wrong. The fix is naming by role, along two axes
that combine into most of what a UI actually needs:
| Category | Example token | Maps to |
|---|---|---|
| Surface | color.background.surface |
Card, panel, sheet backgrounds |
| Brand | color.background.brand |
Primary actions, highlighted state |
| Text |
color.text.primary /
.secondary
|
Body copy by emphasis level |
| Border | color.border.default |
Dividers, input outlines |
| Interactive state |
color.action.primary.hover /
.active / .disabled
|
State variants of one control |
| Feedback |
color.feedback.success /
.warning / .danger /
.info
|
Status and validation messaging |
The test for a good semantic name: it should still make sense after the exact color it resolves to has been swapped. If a name only makes sense while it happens to be red, orange, or blue, it's describing appearance, not role, and it belongs one tier down at the primitive layer instead.
§ 06
Light and Dark Without Duplication
The instinct when adding a dark theme is to design a second, parallel palette from scratch. That doubles the ramp-building work from section three and guarantees the two palettes drift apart the first time either one gets retuned in isolation. The structural fix: keep the exact same semantic token names in both themes, and change only which primitive step each one resolves to.
color.background.surface might resolve to
gray.50 in a light theme and gray.900 in
a dark one; color.text.primary flips the same pair in
reverse. Nothing about the semantic layer or anything built on top
of it — components, layouts — needs to know a theme switch
happened at all. It's worth noting dark surfaces in mature systems
are rarely true black at every elevation either; a slightly
lifted, low-chroma dark step reads as more legible and less harsh
than pure #000 once real content sits on top of it.
color.background.surface never know a theme switch
happened.
In practice that alias swap is usually just a custom-property
reassignment under a theme selector — the semantic layer,
expressed as CSS variables, gets a second definition scoped to a
[data-theme="dark"] attribute or a
prefers-color-scheme: dark media query, and every
component keeps referencing the same semantic variable name either
way:
:root {
--color-background-surface: var(--gray-50);
--color-text-primary: var(--gray-900);
--color-border-default: var(--gray-200);
}
[data-theme="dark"] {
--color-background-surface: var(--gray-900);
--color-text-primary: var(--gray-50);
--color-border-default: var(--gray-700);
}
§ 07
Contrast Is Downstream, Not Separate
Every semantic pairing that will actually appear together on screen — text on a surface, an icon on a brand background, a danger label on its own fill — needs to clear a real WCAG contrast ratio, and the exact math for that lives in the companion guide on color contrast rather than being repeated here. What belongs in this guide is when that check happens: at ramp-definition time, against every pairing the semantic layer actually produces, not as a one-off audit run against finished screens after the fact.
A short pairing matrix — text-on-surface, text-on-brand, icon-on-surface, and so on — built alongside the ramp and re-verified any time a step gets retuned catches what a per-screen audit misses: a single ramp adjustment can silently break a dozen pairings across a product at once, and the earlier that shows up, the cheaper it is to fix.
§ 08
Tokens as Data: the DTCG Color Type
The W3C Design Tokens Community Group format defines
$type: "color" as a structured value — a
colorSpace string plus a
components array, with an optional
hex fallback for tools that still expect one — rather
than a bare hex string. That matters here specifically because it
lets a primitive token carry its real OKLCH values as the source
of truth while still shipping a computed hex for tooling that
hasn't caught up:
{
"color": {
"blue": {
"500": {
"$type": "color",
"$value": {
"colorSpace": "oklch",
"components": [0.55, 0.16, 250],
"hex": "#3d6fe0"
}
},
"700": {
"$type": "color",
"$value": {
"colorSpace": "oklch",
"components": [0.38, 0.14, 250],
"hex": "#1f3e8c"
}
}
},
"background": {
"brand": { "$value": "{color.blue.500}" },
"brand-hover": { "$value": "{color.blue.700}" }
}
},
"button": {
"primary": {
"background": { "$value": "{color.background.brand}" },
"background-hover": { "$value": "{color.background.brand-hover}" }
}
}
}
The brace syntax — {color.blue.500} — is a reference
to another token by its path, resolved the same way regardless of
which tier it crosses, which is what makes the primitive →
semantic → component chain from section four expressible as
literal, machine-readable data rather than just a naming
convention teams have to remember to follow by hand.
§ 09
Five Pitfalls That Look Like Polish
-
01
Picking steps in a color picker instead of a ramp function. A single hand-tweaked step breaks the systematic relationship the rest of the ramp relies on — future retuning has to special-case that one step forever, and nobody downstream can tell which steps are "real" and which were nudged by eye under deadline.
-
02
Naming a semantic token after its current appearance.
color-red-erroris a primitive description wearing a semantic token's clothes, the same failure mode section five describes — the day the error color moves off red, every consumer of that name is quietly wrong. -
03
Building dark mode as a second full palette. Two independently maintained ramps drift apart the first time either gets retuned alone, doubling both the maintenance burden and the odds of a contrast regression that only shows up in one theme.
-
04
Sharing one alias across unrelated roles because they currently match. Pointing both
color.border.defaultandcolor.text.disabledat the same primitive step because they happen to look right together today means a future retune of one role for one reason silently drags the other along with it. Give each role its own alias even when it resolves to the same value right now — cheap to add early, expensive to untangle later. -
05
Holding chroma flat across the whole ramp. Skipping the taper from section three because a constant
Cseemed simpler means the lightest and darkest steps get silently clipped to whatever the display can render — two steps authored as different colors can end up rendering identically at the extremes.
§ 10
Quick Reference
| Concept | Typical value | Note |
|---|---|---|
| oklch() L range | 0–1 or 0%–100% | 0 = black, 1 = white, perceptual |
| oklch() C range | 0–≈0.4 | In-gamut for sRGB; higher in P3 |
| oklch() H range | 0–360deg | Cyclical, 360 wraps to 0 |
| Typical ramp size | 10–11 steps | e.g. 50…900, 950 |
| Token tiers | primitive → semantic → component | Never skip a tier or point sideways |
| Dark mode | remap aliases | Same semantic names, both themes |
DTCG $type: "color" |
colorSpace + components [+ hex] | Structured, not a bare string |
| Contrast check timing | at ramp-definition time | Not a final-screen audit pass |
None of the specific lightness percentages or step counts in this guide are the point — a team will retune the exact numbers to fit its own brand, and that's expected. What's meant to outlast any single value is the separation underneath: a ramp generated by a rule rather than picked by eye, semantic names chosen for role rather than appearance, and theme switching handled as an alias reassignment rather than a second palette. Keep those three layers distinct, and a rebrand or a new dark theme only ever touches the primitive tier — every semantic name and every component built on top of it keeps working without anyone having to go find and update it by hand.