# Primitive and semantic colour tokens > Ramps hold every colour you're allowed to use. Semantic tokens say which one goes where, and point at different steps in light and dark. - Source: https://flashdsm.com/blog/primitive-vs-semantic-colour-tokens - Published: 2026-09-11 - Author: Flash DSM Team - Track: Fundamentals - Reading time: 6 min read --- ## Two layers, two jobs A colour system that lasts has two layers. The primitive layer is a set of ramps: every colour you're allowed to use, named by hue and step. The semantic layer is a short list of jobs, like surface, text and primary, each pointing at one ramp step. Components only ever see the second layer. The split exists because the layers answer different questions. "Which blues do we have?" is a palette question, and the answer rarely changes. "Which blue is the button?" is a usage question, and the answer is different in dark mode, in high contrast, and in your second brand if you ever get one. Put both answers in one layer and every new theme turns into a find-and-replace. ## Building a ramp A ramp is one hue at a series of lightness steps. The convention that won is numbers that run from light to dark: 50, 100, 200 and so on to 900, often with a 950. Some systems run 0 to 1000 so the two ends have names too. The examples in this post do that, with 0 as white. Why around 11 steps? That's enough to cover what an interface needs, with a spare or two: - near-white tints for page backgrounds and subtle fills - light steps for borders, dividers and hover backgrounds - a mid step strong enough for fills and icons - dark steps for text, pressed states and dark-theme accents - near-black steps for dark-theme surfaces Go below about 8 steps and you run out of room for hover and pressed states. Go past about 13 and neighbouring steps become hard to tell apart, so people choose between them by guessing. Numbered steps also leave gaps: if you need something between 500 and 600, 550 is right there and nothing has to be renamed. ### Step by perceived lightness, not HSL The mistake in almost every hand-made palette is stepping HSL lightness evenly. HSL's lightness isn't perceptual. Pure yellow and pure blue both sit at `hsl(_, 100%, 50%)`, yet yellow `#FFFF00` is 1.07:1 against white and blue `#0000FF` is 8.59:1. So your blue-500 and yellow-500 behave nothing alike, and every semantic mapping has to be tuned by hand for each hue. OKLCH fixes most of that. Its L channel is built to track perceived lightness, so hues set to the same L read as roughly the same weight. Set a blue and an amber to L 0.62 and they measure 3.66:1 and 3.71:1 against white. Ramps across hues line up, and a mapping that works for one hue mostly works for the others. ```css --blue-100: oklch(0.93 0.03 255); --blue-300: oklch(0.80 0.08 255); --blue-500: oklch(0.62 0.15 255); --blue-700: oklch(0.47 0.12 255); --blue-900: oklch(0.32 0.08 255); ``` Notice that chroma, the middle number, peaks in the middle of the ramp and tapers at both ends. Very light and very dark colours can't hold much chroma inside sRGB. Amber (hue 80) at L 0.47 is already outside sRGB at a chroma of 0.10, and a colour outside the gamut gets mapped back in by the browser, often with a visible hue shift. Taper chroma toward the ends and check each step lands in gamut. > [!NOTE] > OKLCH lightness and WCAG contrast use different maths. Equal L gets you close, not identical, so still check the pairs that matter. The [contrast post](/blog/contrast-wcag-aa-in-practice) has a ten-line checker. ## The semantic layer Semantic tokens should be few and boring. A working set for most products: - `surface`, `surface-raised`, `surface-sunken` for backgrounds - `text` and `text-muted` for copy - `border` and `border-strong` for dividers and control outlines - `primary`, `primary-hover` and `on-primary` for the main action - `success`, `warning`, `danger` and `info`, each with its own `on-` pair The `on-` tokens do more work than they get credit for. `on-primary` is the colour for text and icons that sit on a primary fill. It exists because the right answer changes: white on a dark blue fill, near-black on a light blue one. Give every fill an `on-` partner and contrast becomes a property of the system instead of something every engineer checks by eye. ## One name, two themes Here's what makes the semantic layer a theme: each token points at a different step in light and dark. This mapping uses a grey ramp whose 50 to 950 steps are Tailwind v3's gray values, plus a blue and a red ramp. | Semantic token | Light | Dark | |---|---|---| | `surface` | `neutral-0` | `neutral-900` | | `surface-raised` | `neutral-0` plus a shadow | `neutral-800` | | `surface-sunken` | `neutral-50` | `neutral-950` | | `text` | `neutral-900` | `neutral-50` | | `text-muted` | `neutral-500` | `neutral-400` | | `border` | `neutral-200` | `neutral-700` | | `border-strong` | `neutral-500` | `neutral-400` | | `primary` | `blue-600` | `blue-400` | | `on-primary` | `neutral-0` | `neutral-900` | | `danger` | `red-600` | `red-400` | Dark isn't the light column flipped upside down. `text-muted` moves from 500 to 400 because `neutral-500` on the dark surface only reaches 3.67:1, below the 4.5:1 body text needs. `primary` moves from 600 to 400 because `blue-600` as link text on the dark surface gets 3.43:1. Raised surfaces get lighter in dark mode, because shadows barely show on a dark background and lightness does the job of elevation instead. [Dark mode is a second theme](/blog/dark-mode-is-a-second-theme) goes further. The pairs that matter, measured: | Pair | Light | Dark | |---|---|---| | `text` on `surface` | 17.74:1 | 16.98:1 | | `text-muted` on `surface` | 4.83:1 | 6.99:1 | | `on-primary` on `primary` | 5.17:1 | 6.98:1 | ## Common mistakes ### Naming semantics by hue `--color-blue` as a semantic token is a primitive in disguise. The day primary turns green, you either have a token called blue that's green or a rename across the codebase. Name the job: `primary`, `link`, `info`. The same goes for `--color-light-grey-bg`, which in dark mode is neither light nor grey. ### Too many semantics Every semantic token is a question someone answers each time they style something. `surface-card`, `surface-panel`, `surface-tile` and `surface-popover` that resolve to the same step in every theme are one token with four names. Merge them. If two semantic tokens have pointed at the same step in every theme for six months, they're the same token. And if you can't list your colour semantics from memory, some of them are duplicates. ### Aliasing semantics to semantics, five deep `--button-bg` points at `--color-action`, which points at `--color-primary`, then `--color-brand`, then `--color-brand-base`, and finally `--blue-600`. Each hop seemed reasonable when someone added it. Together they mean nobody can answer "what colour is the button?" without opening four files, and a change at any hop has a blast radius nobody can predict. Keep the chain short. A semantic token points at a primitive. A component token, if you have them, points at a semantic. That's two hops from component to value, and you never need more. ### Using primitives in components `background: var(--blue-600)` inside a component skips the semantic layer, so dark mode can't reach it. This one is easy to lint for: a primitive name showing up outside your token files is almost always a bug. ## How Flash does it In Flash, the AI only picks hues. The ramps, with steps 0 to 1000, and the semantic aliases are computed, with light and dark pointing at different steps, and text and fill pairs are checked against WCAG AA. You can adjust either theme under Foundations > Colors with "Edit light mode values" and "Edit dark mode values". [Editing foundations](/blog/editing-foundations) walks through it. When you have to add an eleventh semantic token and want it to fit the other ten, read [naming design tokens](/blog/naming-design-tokens).