// 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.

INTERMEDIATE · 14 MIN READ · COLOR THEORY

§ 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 versus OKLCH lightness held constant across three hues Two rows of three color swatches each. The top row holds HSL lightness at fifty percent across a yellow, a green, and a blue, and the swatches read as visibly different brightnesses. The bottom row holds OKLCH lightness at seventy-five percent across the same three hue positions, and the swatches read as much closer in perceived brightness. hsl(H, 100%, 50%) — same "L", different perceived brightness hsl(60,100%,50%) hsl(120,100%,50%) hsl(240,100%,50%) oklch(75% 0.15 H) — same L, much closer perceived brightness oklch(75% .15 95) oklch(75% .15 155) oklch(75% .15 255)
FIG. 01 — Rendered with live 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:

/ tonal_ramp_steps
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:

/ semantic_color_roles
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.

One semantic token resolving to different primitive steps per theme A diagram showing a light-theme ramp strip and a dark-theme ramp strip, each with one step highlighted, with dashed lines from both highlighted steps converging on a single shared semantic token box labeled color background surface. LIGHT THEME gray.50 selected DARK THEME gray.900 selected color.background.surface one name, two resolved values
FIG. 02 — The semantic name never changes between themes; only the alias assignment behind it does. Components built on 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-error is 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.default and color.text.disabled at 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 C seemed 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

/ color_palette_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.

COLOR THEORY DESIGN TOKENS OKLCH TONAL RAMPS DARK MODE