Turning a palette into a design system: scales, tokens and CSS variables
Five nice colours are not a colour system. Here is how to get from a generated palette to a named, tested set of tokens your whole team can build against.
The gap between a palette and a system
A palette answers "which colours?". A colour system answers "which colour, where, and what happens when it is on a dark background, disabled, hovered or in an error state?". The second question is what actually gets asked fifty times a week once a project is underway, and answering it once — in tokens — is what stops a codebase accumulating eleven slightly different greys.
The move from one to the other has three steps: build tonal scales, name them by role, then ship them as variables.
Step 1: turn each colour into a scale
Take the palette out of the generator and pick the two or three colours doing real work — usually a brand colour, a neutral and an accent. Each becomes a scale of tints and shades, conventionally numbered 50 (lightest) to 900 (darkest), matching the convention Tailwind and Material both use.
The generator will do this for you: open the Tonal scale panel, choose which palette colour to build from, and you get all ten steps with copy buttons for CSS, SCSS or Tailwind. It works in OKLCH for the reason described below. If you would rather build a scale by hand, or want to understand what the panel is doing, read on.
The reliable way to build one: hold hue and saturation roughly steady and step the lightness. In HSL that is a single number changing:
--brand-50: hsl(190 60% 96%);
--brand-100: hsl(190 58% 90%);
--brand-200: hsl(190 56% 80%);
--brand-300: hsl(190 55% 68%);
--brand-400: hsl(190 55% 60%);
--brand-500: hsl(190 55% 52%); /* the colour you picked */
--brand-600: hsl(190 56% 43%);
--brand-700: hsl(190 58% 34%);
--brand-800: hsl(190 60% 26%);
--brand-900: hsl(190 62% 18%);
Two refinements make a scale look professional rather than mechanical. First, nudge saturation up slightly at the dark end and down at the light end — pale tints with full saturation look chalky. Second, if you are working in OKLCH, step the lightness there instead; because its lightness axis is perceptually uniform, evenly spaced numbers produce evenly spaced steps, which HSL cannot promise.
Both refinements are what the built-in Tonal scale panel applies: it converts your colour to OKLCH, steps the lightness across ten perceptually even targets, damps the chroma at the pale end, and then reduces chroma further wherever a step would fall outside the sRGB gamut — which keeps the hue steady instead of letting it drift as naive clipping would.
Check the scale by squinting at it. Every step should read as clearly different from its neighbours, with no two rungs collapsing into each other and no sudden jump in the middle.
Step 2: name by role, not by colour
This is the step teams skip and later regret. --teal-500 describes what a colour is; --color-action describes what it is for. When the brand changes — and it will — role names survive and hex-based names become lies.
The usual pattern is two layers:
- Primitive tokens — the raw scale.
--teal-500,--grey-100. Never referenced directly in components. - Semantic tokens — roles, pointing at primitives.
--color-surface,--color-text,--color-text-muted,--color-border,--color-action,--color-action-hover,--color-danger,--color-success. These are what components use.
:root {
/* primitives */
--teal-500: #3DB3CB;
--teal-700: #24788B;
--ink-900: #0F2228;
--ink-500: #5A6B70;
--paper-50: #EEF3F4;
/* semantic */
--color-surface: #FFFFFF;
--color-surface-alt: var(--paper-50);
--color-text: var(--ink-900);
--color-text-muted: var(--ink-500);
--color-action: var(--teal-500);
--color-action-hover:var(--teal-700);
--color-border: #E1EAEC;
}
The payoff arrives at dark mode, when you re-point the semantic layer at different primitives and every component follows without a single edit. See designing a dark mode palette for how to choose those values.
Step 3: decide how many colours you actually need
A surprisingly small set covers most interfaces:
| Role | Typical count | Notes |
|---|---|---|
| Neutrals | 7–10 steps | The workhorse. Backgrounds, borders, text, dividers. Give this the most steps. |
| Brand / action | 4–6 steps | Default, hover, active, disabled, plus a pale tint for backgrounds. |
| Accent | 3–4 steps | Optional. Highlights, illustrations, data. |
| Feedback | 3 steps × 4 | Success, warning, danger, info — each needs a text, a background and a border tone. |
If your palette has more than that, you have a mood board, not a system.
Step 4: test the pairs, not the swatches
A token is only usable if it passes contrast against the surfaces it will sit on. Before you lock the system, run the combinations you will actually ship through the contrast checker:
- body text on the default surface — needs 4.5:1 for WCAG AA (each step in the Tonal scale panel is already labelled with whether it carries black text, white text, either or neither);
- muted text on the default surface — the one that usually fails;
- button label on the action colour — check both default and hover states;
- text on any pale tint background you plan to use for callouts;
- borders and icons against their background — 3:1 for non-text contrast.
Record the passing pairs in the system's documentation. "Use --color-text-muted on --color-surface only, never on --color-surface-alt" is exactly the kind of rule that saves an accessibility audit later. The full detail is in our guide to WCAG contrast.
Step 5: ship it in the format your stack wants
The generator's export panel produces the same palette in the four formats most projects need:
- CSS custom properties — the default for anything modern; they cascade, they respond to media queries, and they can be changed at runtime.
- OKLCH — the same colours written as
oklch()with a hex fallback on the line above, so older browsers keep working. Worth shipping if you expect to hand-tune the palette later. - SCSS variables — compile-time only, so they cannot be swapped for theming, but they work well as the primitive layer feeding a generated stylesheet.
- Tailwind config — drops into
theme.extend.colorsso the palette becomes utility classes. - JSON tokens — the interchange format for design-token pipelines such as Style Dictionary, and the easiest thing to hand to a native app team.
Whichever you pick, keep one source of truth. A palette that lives in a CSS file, a Figma library and a Tailwind config independently will drift within a month; generate the others from one of them.
Related guides: HEX, RGB, HSL & OKLCH · Dark mode palettes · Accessible colour