Foundations: color, in OKLCH
A practical guide to OKLCH for product color systems: ramps, perceptual lightness, contrast, semantic tokens, and recipes.
OKLCH is useful because it lets designers and engineers talk about color in a way that is closer to how the color behaves. Lightness is lightness. Chroma is color intensity. Hue is the color family. That separation makes product color systems easier to tune.
In older color spaces, two colors with similar-looking numeric lightness can feel very different on screen. A blue and a yellow with the same HSL lightness may not appear equally bright. OKLCH does a better job of making lightness changes feel perceptual, which is exactly what a design system needs when building ramps, surfaces, borders, and state colors.
Think in three controls
OKLCH has three main parts:
- L: lightness, from dark to light.
- C: chroma, from neutral to vivid.
- H: hue angle, the color family.
That model maps well to product decisions. If a border is too strong, lower chroma or adjust lightness. If a brand button fails contrast, change lightness before randomly dragging the color picker. If a danger background feels too loud, reduce chroma while keeping the red hue recognizable.
The point is not that OKLCH solves color automatically. The point is that it gives you better handles.
Build ramps with perceptual lightness
A color ramp is a sequence of related colors, usually from light to dark. Ramps are where OKLCH starts to pay off.
I usually choose a hue, define the role of the ramp, then set lightness steps deliberately. A UI ramp might need very pale backgrounds, subtle borders, readable text, and a strong action color. Those are different jobs, so the steps should not be evenly distributed just because a tool can generate them.
A practical brand ramp might include:
- 98: tinted page background.
- 94: subtle surface.
- 88: selected or hover background.
- 76: decorative fill or soft accent.
- 62: primary action.
- 48: pressed action or strong text on light.
- 34: dark emphasis.
Chroma should often rise in the middle and fall at the extremes. Very light colors can become noisy if chroma is too high. Very dark saturated colors can vibrate or lose detail. OKLCH makes those adjustments easier to reason about.
Contrast is a check, not a vibe
Perceptual lightness helps, but it does not remove the need to check contrast. Text, icons, focus rings, disabled states, and charts all need verification against their actual backgrounds.
I check contrast at the semantic-token level, not only the primitive level. It is not enough for blue-700 to pass on white if the product uses action-primary-text on action-primary-bg. The pair is the contract.
Focus states deserve special attention. A focus ring that looks good on a white background may disappear on a tinted surface or a destructive button. Error text that passes on the page background may fail inside a pale red alert. Tokens should be tested in combinations that actually occur.
Semantic tokens keep ramps usable
Primitive ramps are ingredients. Semantic tokens are decisions.
I like primitives such as:
- color-blue-98
- color-blue-88
- color-blue-62
- color-red-94
- color-red-58
- color-neutral-20
But product code should mostly use semantic names:
- color-bg-page
- color-bg-surface
- color-text-primary
- color-text-muted
- color-border-subtle
- color-action-primary-bg
- color-action-primary-text
- color-danger-bg
- color-danger-text
This indirection is what lets a team retune the color system without rewriting every component. It also prevents designers and engineers from arguing about whether a button is "blue-62" when the real question is whether it is the primary action.
Practical recipes
For a primary button, start with a middle-lightness brand color with enough chroma to feel active. Check white or near-white text on top. Create hover and pressed states by changing lightness in visible but restrained steps. Do not rely on chroma alone for state.
For subtle selected backgrounds, use high lightness and low-to-moderate chroma. The selected state should be visible, but it still has to support text, icons, and focus rings.
For danger states, keep the hue recognizable but avoid making every destructive surface highly saturated. A pale danger background, readable danger text, and a strong destructive button may all come from the same hue family with different lightness and chroma.
For charts, do not simply pick the loudest colors in the palette. Chart colors need separation from each other, sufficient contrast against the plot background, and a plan for labels, hover states, and color-blind review. Sometimes the right chart palette is less saturated than the brand palette.
For dark mode, do not invert the light ramp mechanically. Dark surfaces need careful lightness spacing so elevation, borders, and text hierarchy remain visible. Chroma often needs adjustment because saturated colors can feel stronger on dark backgrounds.
Where OKLCH can still surprise you
OKLCH values can point outside a display's gamut, especially with high chroma. Modern tools and browsers can handle many cases, but product systems still need review on real screens. If a color clips, it may not look like the color you intended.
Design tools, CSS output, screenshots, and exported assets may not all manage color the same way. Keep a small visual test page with ramps, text pairs, alerts, buttons, focus states, and charts. That page will catch problems faster than inspecting token JSON.
Exercises
Build one neutral ramp and one brand ramp in OKLCH. Use them to create semantic tokens for page background, surface, border, primary text, muted text, primary action, selected background, and focus ring.
Then test those tokens in four components: button, input, alert, and table row. Check default, hover, focus, disabled, and error states. If you have to invent local colors inside the component, the token set is missing a decision.
Finally, retune the brand hue while keeping the semantic names. If the product still works, the system is doing its job. If everything breaks, the palette was doing too much work directly.