Color Systems for UI
Most interface color problems have the same root cause: the palette was assembled one hex value at a time. Someone picked a brand blue, someone else needed a lighter version and eyeballed it, a third person added a red for errors that turned out to be much darker than the blue at the same nominal lightness. The result is a set of colors that don't relate to each other, some of which fail contrast requirements, and none of which can be systematically adapted for dark mode.
A color system fixes this by generating colors from rules rather than choosing them individually. Ramps are produced by varying lightness and chroma along a curve. Contrast is verified as a property of the palette, not discovered per screen. And colors are assigned semantic roles — color-action-primary, not blue-600 — so meaning survives a rebrand.
The technical enabler is perceptual color space. HSL, which every designer learned first, is badly non-uniform: hsl(60, 100%, 50%) (yellow) and hsl(240, 100%, 50%) (blue) claim the same lightness and differ enormously in perceived brightness. OKLCH fixes that, and it's now supported everywhere that matters.
TL;DR
- HSL lies about lightness. Equal
Lvalues look wildly different across hues. - OKLCH is perceptually uniform — equal lightness looks equally light, so generated ramps are consistent.
- Build ramps by rule (a lightness curve, chroma peaking mid-ramp), not by picking values individually.
- Assign semantic roles on top of the ramp; components reference roles. See Design Tokens.
- WCAG contrast: 4.5:1 for body text, 3:1 for large text and UI components. AAA is 7:1.
- Never encode meaning in color alone — around 8% of men have a color vision deficiency.
- Dark mode is not an inversion: reduce chroma, avoid pure black and pure white, rethink elevation.
- Verify contrast in the build, not by checking screens afterward.
Quick Example
Generating a consistent ramp in OKLCH, where the same rule produces the same perceived result for any hue:
The important property: blue-600 and red-600 now have genuinely equal perceived lightness, so a danger button and a primary button sit at the same visual weight. Doing this in HSL requires hand-tuning every hue and breaks the moment a new one is added.
Core Concepts
Why HSL fails
This is why a hand-built palette has a yellow that's unreadable on white and a blue that's fine, at nominally identical lightness. It's also why programmatic ramps in HSL produce inconsistent results across hues.
OKLCH
Practical advantages beyond uniformity:
- Predictable ramps. One lightness curve works for every hue.
- Sane interpolation. Gradients and color mixing don't pass through muddy intermediate colors the way sRGB interpolation does.
- Wide gamut. OKLCH can express P3 colors that hex notation cannot.
- Consistent adjustment. "10% lighter" means the same thing regardless of hue.
Browser support is broad in current versions; @supports (color: oklch(0 0 0)) gives you a hex fallback if you need to support older ones. Note that some OKLCH values fall outside the sRGB gamut and get clipped — check chroma at high and low lightness where the gamut narrows.
Contrast
Running this as a build step over every semantic foreground/background pair catches violations at the source, where one fix corrects every screen. Auditing screens afterward finds the same problems later and more expensively. See Accessibility.
WCAG 2's formula is known to be imperfect — it under-penalizes some light-on-light combinations and over-penalizes some dark ones. APCA, developed for WCAG 3, models perception better. Use WCAG 2 for compliance today, and consider APCA as a secondary check for cases where the WCAG number feels wrong.
Semantic roles
The palette is raw material. The role assignment is the system:
Each interactive role needs its full state set. Missing hover and active states is why so many custom buttons feel unresponsive compared to system ones.
Color and meaning
Around 8% of men and 0.5% of women have a color vision deficiency, most commonly red-green. An interface where "red means error, green means success" and nothing else distinguishes them is unusable for them.
The same rule applies to charts (use pattern, shape, or direct labels alongside color), status indicators, and required form fields. Testing with a color-blindness simulator takes a minute and catches most of it.
Dark Mode
Dark mode is a distinct design, not an inversion.
The adjustments that matter:
- Avoid pure black and pure white. Maximum contrast causes halation — light text appears to bleed on a black background, which is fatiguing.
#121212and a near-white are the common choices. - Reduce chroma. Saturated colors on dark backgrounds vibrate and appear to glow. Cut chroma by roughly 20–40%.
- Raise lightness for accent colors. A color readable on white is usually too dark on a dark background.
- Elevation inverts. In light mode, higher surfaces have shadows; in dark mode, higher surfaces are lighter, because shadows are invisible.
- Re-verify all contrast. Ratios change completely; a pair that passed in light mode tells you nothing about dark.
Because the semantic token layer holds these mappings, implementing this is a second token file rather than a second stylesheet. See Dark Mode & Theming.
Best Practices
Generate ramps, don't pick colors
A lightness curve and a chroma curve, applied to each brand hue, produce a palette where every step relates predictably to every other. Hand-picking guarantees inconsistency at the boundaries between hues.
Keep the palette small
Two or three brand hues, a neutral ramp, and four feedback colors covers nearly every interface. Eight brand ramps is eight opportunities for inconsistency and a system where nobody knows which blue to use.
Tint your grays
Pure neutral gray (oklch(L 0 0)) looks sterile beside a colored brand. A tiny chroma pulled toward the brand hue — oklch(0.5 0.01 250) — makes the whole interface feel cohesive. It's a small change with a disproportionate effect.
Verify contrast in CI
A build step over every semantic pair, failing on violation. Contrast is a property of the token set, so this is the one place where checking it is both cheap and complete.
Never rely on color alone
Icons, text labels, shapes, or patterns alongside color. This benefits color-blind users, users in bright sunlight, and anyone using a poorly calibrated display.
Give focus indicators real contrast
The focus ring must reach 3:1 against its background — both the page background and any component background it sits on. A subtle focus ring is an accessibility failure, and it's the single most common one in custom component libraries.
Use color-mix() rather than more tokens
Deriving state variations from a base color keeps the token set small and guarantees the relationship holds when the base changes. Interpolating in OKLCH avoids the muddy results sRGB mixing produces.
Test on real displays
Colors shift substantially between an OLED phone, a cheap laptop panel, and a calibrated monitor. A palette that only works on a designer's display is a palette that mostly doesn't work.
Common Mistakes
Assuming equal HSL lightness looks equal
Failing contrast on secondary text
Light gray placeholder and helper text is probably the most widespread contrast failure on the web.
Color as the only status signal
Inverting for dark mode
Hardcoding hex values in components
A palette with no neutral ramp
FAQ
Should I use OKLCH or stick with hex?
Use OKLCH for authoring and generating the palette — that's where perceptual uniformity pays off. Emit hex or rgb() fallbacks if you support older browsers. Current browsers all support OKLCH, and the reasoning quality it enables (consistent ramps, sane interpolation, predictable adjustments) is worth adopting even if the output is converted.
How many steps should a ramp have?
Nine to eleven (50, 100, 200 … 900, 950) is the common convention and is generally right. Fewer forces awkward compromises for hover states and subtle backgrounds; more produces steps nobody can distinguish or choose between. Tailwind, Radix Colors, and most design systems converge on roughly this granularity for good reason.
What about the P3 wide gamut?
P3 displays show more saturated colors than sRGB, and OKLCH can express them. Use them as an enhancement with an sRGB fallback via @supports or color-gamut queries — a slightly more vivid accent on capable displays. Don't build critical contrast relationships on P3-only colors, since they'll be clipped on sRGB displays and the ratio changes.
How do I pick brand colors in the first place?
That's a brand decision rather than a systems one, and the system's job is to make whatever hue you're given work. Practical constraints worth applying: avoid hues where the sRGB gamut is very narrow at the lightness you need, verify the primary action color reaches 4.5:1 against your background at a usable lightness, and check that your danger and success colors are distinguishable under a deuteranopia simulation.
Is WCAG 2 contrast reliable?
Mostly, and it's what compliance requires. Its known weaknesses: it treats the two directions of a light/dark pair identically when perception doesn't, and it can pass combinations that are genuinely hard to read while failing some that are fine. APCA, developed for WCAG 3, models this better. Meet WCAG 2 AA for compliance and use APCA or your own judgment as a secondary check where the number feels wrong.
How do I handle color in data visualization?
Different constraints entirely: categorical palettes need maximally distinguishable hues (and must survive color-blindness simulation), sequential palettes need perceptually uniform progression, and diverging palettes need a meaningful midpoint. Never use a rainbow scale for continuous data — it creates false boundaries that aren't in the data. Generating sequential scales in OKLCH gives you the perceptual uniformity these palettes require for free.
Related Topics
- Design Tokens — Where semantic color roles live
- Dark Mode & Theming — Implementing the second palette
- Design Systems — Distributing the palette across products
- Accessibility — Contrast requirements in context
- Web Typography — The other half of readability
- CSS —
oklch(),color-mix(), and custom properties - Figma for Developers — Color variables in the design source