CSS Color Systems: HSL, oklch, and Building a Design Token Palette
July 7, 2026 · 9 min read
CSS Color Systems: HSL, oklch, and Building a Design Token Palette
Hex codes like #3b82f6 have one job: they store color data. They do that job fine, but they tell you nothing about relationships between colors, give you no leverage when tweaking a palette, and make dark-mode support an exercise in manual bookkeeping. Moving to a structured color system — built on HSL or oklch and surfaced through CSS custom properties — turns your palette from a list of magic numbers into a maintainable design layer. This guide walks through the fundamentals, the tradeoffs, and the practical steps to build a token-based system you'll actually want to maintain.
Why Hex Codes Are Holding You Back
Consider a brand palette: #1a56db, #1c64f2, #3f83f8, #76a9fa. These are four blues from a popular component library. Can you tell by looking at them which is darker? Which is more saturated? What you'd need to do to derive a fifth shade for a hover state?
You cannot. The hex format encodes R, G, B channels in base-16, and the human visual system does not perceive color along those axes. Adjusting #1c64f2 by shifting the red channel down by 0x10 produces an unpredictable result — you might accidentally desaturate, shift hue, or darken in a non-uniform way.
This has real maintenance consequences. Teams end up with ad-hoc color lists, slight inconsistencies between components, and no systematic way to generate tints, shades, or analogous colors. When a designer says "make this button 10% lighter," the developer has to eyeball it or open a color picker.
The solution is not a better picker — it's a better coordinate system. HSL and oklch both express color in terms of dimensions that match how designers and developers think about it: hue, lightness, and chroma/saturation. That means you can write formulas instead of guessing hex values.
A quick demonstration of the difference:
/* Hard to reason about */
background-color: #1c64f2;
/* Immediately tells you: blue, medium lightness, high saturation */
background-color: hsl(221deg 83% 53%);
/* Perceptually uniform blue — oklch */
background-color: oklch(55% 0.2 264);
The last two lines carry information the hex code does not.
HSL: The Developer-Friendly Color Space
HSL stands for Hue, Saturation, Lightness. Hue is a degree value on the color wheel (0 = red, 120 = green, 240 = blue, 360 wraps back to red). Saturation is 0–100%, where 0% is a gray and 100% is fully vivid. Lightness is 0–100%, where 0% is black and 100% is white.
The practical superpower of HSL is that you can generate an entire tonal scale for a color by holding hue and saturation constant and varying lightness:
:root {
--blue-50: hsl(221deg 83% 96%);
--blue-100: hsl(221deg 83% 90%);
--blue-200: hsl(221deg 83% 80%);
--blue-300: hsl(221deg 83% 70%);
--blue-400: hsl(221deg 83% 62%);
--blue-500: hsl(221deg 83% 53%);
--blue-600: hsl(221deg 83% 44%);
--blue-700: hsl(221deg 83% 36%);
--blue-800: hsl(221deg 83% 27%);
--blue-900: hsl(221deg 83% 18%);
}
One hue, one saturation, ten shades. If the designer decides the brand blue should shift 5 degrees warmer, you update a single number.
HSL also makes hover states trivial to express in CSS using calc():
.button {
--hue: 221deg;
--sat: 83%;
--lit: 53%;
background-color: hsl(var(--hue) var(--sat) var(--lit));
}
.button:hover {
background-color: hsl(var(--hue) var(--sat) calc(var(--lit) - 8%));
}
The limitation of HSL is that it is not perceptually uniform. A yellow at hsl(60deg 83% 53%) appears far brighter than a blue at hsl(221deg 83% 53%) even though both have the same lightness value. That matters when you need consistent contrast across a multi-hue palette.
oklch: Perceptually Uniform Color
oklch (Oklab Lightness-Chroma-Hue) is a CSS Color Level 4 space that is perceptually uniform: equal steps in lightness or chroma produce equal perceived differences regardless of hue. Browser support is solid — Chrome 111+, Firefox 113+, Safari 16.4+, covering over 90% of global traffic as of 2025.
The three axes:
- L — lightness from 0 (black) to 1 (white)
- C — chroma, roughly 0–0.4 for typical sRGB colors (0 is gray)
- H — hue angle in degrees, same wheel as HSL
:root {
/* A scale where each step is perceptually equidistant */
--blue-50: oklch(97% 0.02 264);
--blue-100: oklch(93% 0.05 264);
--blue-200: oklch(85% 0.09 264);
--blue-300: oklch(74% 0.14 264);
--blue-400: oklch(63% 0.18 264);
--blue-500: oklch(55% 0.20 264);
--blue-600: oklch(47% 0.20 264);
--blue-700: oklch(38% 0.18 264);
--blue-800: oklch(30% 0.14 264);
--blue-900: oklch(22% 0.10 264);
}
The critical benefit: if you build a button system with --blue-500 as the base, --red-500 for destructive actions, and --green-500 for success states, all three will have the same perceived lightness at the 500 level. Contrast ratios against a white background will be consistent across hues without manual adjustment.
Use the Color System Builder to generate oklch scales directly from a brand color.
Building a Design Token Palette
A design token is a named CSS custom property that carries semantic meaning, not just a color value. The pattern has two layers: primitive tokens (raw values) and semantic tokens (purpose-driven aliases).
Layer 1: Primitives — the full tonal scale for each hue.
:root {
/* Primitive tokens — hue + shade number */
--color-blue-500: oklch(55% 0.20 264);
--color-red-500: oklch(55% 0.22 29);
--color-gray-700: oklch(38% 0.01 264);
}
Layer 2: Semantic tokens — what the color means in the UI.
:root {
--color-action-primary: var(--color-blue-500);
--color-action-destructive: var(--color-red-500);
--color-text-body: var(--color-gray-700);
}
Components reference only semantic tokens:
.button--primary {
background-color: var(--color-action-primary);
}
.button--danger {
background-color: var(--color-action-destructive);
}
This indirection means rebranding is a single file change. Switching from blue to indigo as your primary means changing one primitive assignment, and every button, badge, link, and tab in the application updates automatically. No component file needs to be touched.
The CSS Variables Generator can scaffold both layers from a starting palette.
CSS Custom Properties for Dynamic Color
CSS custom properties (variables) unlock runtime color manipulation that preprocessor variables like Sass $blue cannot match. Because they resolve at paint time rather than build time, they respond to media queries, JavaScript, and attribute changes.
The most practical pattern is storing color components as separate variables so you can compose colors dynamically:
:root {
--brand-h: 264;
--brand-c: 0.20;
--brand-l: 55%;
}
.card {
background-color: oklch(var(--brand-l) var(--brand-c) var(--brand-h));
border-color: oklch(calc(var(--brand-l) - 10%) var(--brand-c) var(--brand-h));
}
You can also scope overrides to component instances:
.badge--warning {
--brand-h: 80; /* shift to yellow-green */
--brand-c: 0.18;
--brand-l: 62%;
}
The badge--warning rule only overrides three component variables — every consumer of those variables within the element automatically picks up the new values. This is the CSS equivalent of passing props, and it is far more powerful than BEM modifier classes that duplicate entire property sets.
For a system with many hues, consider a utility that generates the component variables per hue at build time using PostCSS or a CSS-in-JS layer, then fall back to vanilla CSS custom properties in the stylesheet.
Dark Mode Without the Pain
The typical dark mode implementation doubles the stylesheet: every color gets a duplicate under @media (prefers-color-scheme: dark) or [data-theme="dark"]. With a token system, you only update semantic tokens.
/* Light mode — default */
:root {
--color-bg-base: oklch(99% 0.002 264);
--color-bg-surface: oklch(96% 0.004 264);
--color-text-primary: oklch(20% 0.01 264);
--color-text-muted: oklch(45% 0.01 264);
--color-border: oklch(88% 0.006 264);
}
/* Dark mode — only semantic tokens change */
@media (prefers-color-scheme: dark) {
:root {
--color-bg-base: oklch(12% 0.01 264);
--color-bg-surface: oklch(18% 0.01 264);
--color-text-primary: oklch(94% 0.005 264);
--color-text-muted: oklch(65% 0.01 264);
--color-border: oklch(28% 0.01 264);
}
}
Zero component changes. Every element using var(--color-bg-base) adapts automatically. The primitive blue scale stays the same in both modes — you only swap which shade is assigned to which semantic role.
For interactive themes (a toggle button, not just the OS setting), use a data-theme attribute instead:
[data-theme="dark"] {
--color-bg-base: oklch(12% 0.01 264);
/* ... */
}
JavaScript sets document.documentElement.setAttribute('data-theme', 'dark'). No class toggling, no body attributes, no JavaScript-injected styles. The Dark Mode Color Palette tool generates paired light/dark token sets with verified contrast ratios.
Generating Harmonious Color Scales
A palette needs more than a brand blue. You typically need at minimum: a primary action color, a neutral/gray scale, and three semantic hues for success, warning, and error states. Generating these so they feel visually related requires keeping perceptual lightness consistent across hue families.
Target WCAG AA (4.5:1) for normal text and 3:1 for large text. With oklch, a lightness of 55% or below against white reliably hits AA for most hues — but always verify, because chroma and hue affect perceived lightness. The exact threshold varies: blue achieves AA at oklch(55% 0.20 264) while yellow may need to drop to oklch(45% 0.18 96).
A minimal harmonious system:
:root {
/* Primary */
--color-blue-500: oklch(55% 0.20 264);
/* Success — same L and C, shifted hue */
--color-green-500: oklch(55% 0.18 145);
/* Warning */
--color-amber-500: oklch(55% 0.18 80);
/* Destructive */
--color-red-500: oklch(55% 0.22 29);
/* Neutral — low chroma, anchored to brand hue */
--color-gray-500: oklch(55% 0.02 264);
}
By anchoring all hues to the same L/C target, status badges rendered side-by-side will appear equally prominent. No hue visually shouts louder than another.
The Color Harmony Generator automates this — input a brand color and it outputs a full system with semantic hues matched on perceptual lightness, plus contrast ratio checks for each token.
Conclusion
The shift from hex codes to a structured color system is not cosmetic. It changes what questions you can answer quickly: "Is this contrast WCAG compliant?" becomes a calculation, not a guess. "How do I support dark mode?" becomes a token swap, not a duplication. "Can we change the brand color?" becomes a one-line edit, not a search-and-replace across 40 components.
The practical path forward: start by converting your existing colors to oklch using a converter, group them into a primitive scale per hue, then layer semantic tokens on top and update your components to reference only the semantic names. The investment pays back within the first redesign cycle.
For the tooling: use the Color System Builder to generate your oklch scales from a brand color, the Color Harmony Generator to derive semantic hues, the Dark Mode Color Palette tool to produce verified dark-mode token pairs, and the CSS Variables Generator to scaffold the final --custom-property declarations. Each tool outputs copy-paste-ready CSS — there is no reason to build this from scratch by hand.
Try these free tools
Free & private — all tools run in your browser, nothing uploaded.