Building a Color System: Semantic Tokens, OKLCH and Dark Mode
Learn how to build a scalable color system using semantic design tokens, OKLCH color space, and bulletproof dark mode — without the CSS variable spaghetti.
Why Most Color Systems Break Down
Most color systems start fine — a handful of hex values, maybe a colors.js file — and then, six months later, you've got --blue-dark-hover-pressed-disabled as an actual variable name and nobody knows what it does. Sound familiar?
The problem isn't picking colors. It's that teams conflate *what a color is* with *what it does*. Raw color values (#3B82F6) and semantic tokens (--color-action-primary) are two completely different things, and conflating them is where the mess begins.
In practice, you need at least two layers: a primitive palette (every raw color your brand uses) and a semantic layer that maps intent to those primitives. The semantic layer is what your components actually consume. When you need to swap themes, you only touch the semantic layer — primitives stay untouched.
Look, this sounds obvious on paper, but I've seen teams at well-funded startups skip it entirely because "we only have three brand colors." Then dark mode comes up in Q3 and suddenly it's a multi-week refactor.
The Case for OKLCH in 2026
If you're still defining your palette in hex or even HSL, OKLCH is worth your time. Introduced as part of CSS Color Level 4, it became broadly available across Chromium, Firefox, and Safari by late 2023 — and by 2026 there's no real excuse not to use it in greenfield projects.
OKLCH stands for Oklab Lightness, Chroma, Hue. What makes it different is *perceptual uniformity* — when you step lightness from 50 to 60, it *looks* like the same visual jump regardless of the hue. With HSL, a yellow at L:50 looks way brighter than a blue at L:50. That inconsistency kills systematic scales.
Here's a quick example. Say you want a 9-step neutral scale. In OKLCH you can linearly interpolate lightness from oklch(0.15 0.01 264) to oklch(0.97 0.005 264) and every step will look evenly spaced to a human eye. Try that with HSL and you'll be manually fudging values forever.
Worth noting: OKLCH also makes it trivial to generate accessible color pairs. You control lightness directly, so hitting WCAG 2.1 AA (4.5:1 contrast ratio) becomes a math problem, not a guess.
One more thing — P3 display gamut colors are easy to express in OKLCH. You can push chroma above roughly 0.37 to get colors that pop on modern Mac and iPhone screens, with an @media (color-gamut: p3) fallback for older displays.
Structuring Your Token Architecture
Here's a token architecture that actually scales. Three tiers: primitives, semantics, and component tokens.
Primitives are your raw values — every shade in your palette, nothing else. Semantics map role to primitive. Component tokens are optional, scoped overrides for a single component like a badge or a button. Most projects don't need the third tier until they're building a white-label product.
Below is a working CSS setup. Notice how the semantic layer references primitives, not raw values — that's the whole point:
/* ── Primitives ── */
:root {
--blue-50: oklch(0.97 0.02 264);
--blue-500: oklch(0.55 0.22 264);
--blue-900: oklch(0.22 0.10 264);
--neutral-50: oklch(0.97 0.005 264);
--neutral-900: oklch(0.13 0.005 264);
}
/* ── Semantic (light) ── */
:root {
--color-bg-base: var(--neutral-50);
--color-bg-elevated: oklch(1 0 0);
--color-text-primary: var(--neutral-900);
--color-action-primary: var(--blue-500);
}
/* ── Semantic (dark) ── */
[data-theme="dark"] {
--color-bg-base: var(--neutral-900);
--color-bg-elevated: oklch(0.18 0.007 264);
--color-text-primary: var(--neutral-50);
--color-action-primary: var(--blue-50); /* lighter in dark mode */
}Your components only ever reference --color-* tokens, never primitives. When you flip to dark mode, you swap one attribute and the whole UI responds. No JavaScript required, no class toggling gymnastics.
Dark Mode That Doesn't Suck
Dark mode done badly is just a light UI with a black background. Done right, it respects the intent behind every color — backgrounds get darker, but text contrast stays high, and subtle surfaces use elevation to create depth rather than hue shifts.
A common mistake: using the same hue for dark backgrounds that you use for dark text. At 8-12px font sizes on a dark oklch(0.13 ...) background, a dark blue text token reads terribly. You want your dark-mode text tokens to be *near-white with a faint hue tint*, not the dark end of your color scale.
Quick aside: prefers-color-scheme is still the right way to default. Wrap your [data-theme="dark"] override with a media query too, so users who haven't explicitly toggled get their OS preference respected automatically:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg-base: var(--neutral-900);
--color-text-primary: var(--neutral-50);
--color-action-primary: var(--blue-50);
}
}If you're building components for a library — like the ones you'd find when you browse the components at Empire UI — this pattern means your components are theme-agnostic by default. They just consume tokens. The consumer decides the theme.
Generating Accessible Scales with OKLCH
Here's where OKLCH really earns its keep. Want to know if your action color passes AA contrast against a white background at 16px? You can calculate it before you ever open a browser.
The formula: contrast ratio is roughly (L1 + 0.05) / (L2 + 0.05) where L values are relative luminance. OKLCH's lightness channel (L) isn't relative luminance directly, but it's close enough that L < 0.4 on a white background will generally pass AA for normal text — you still want to verify, but the eyeballing phase is way shorter.
Honestly, the bigger win is generating your scale programmatically. Instead of picking 9 shades of blue by hand, you write a short script: fix the hue and chroma, vary L from 0.15 to 0.97 in even steps. Every shade is perceptually equidistant. Your designers stop arguing about whether shade 6 looks "too similar" to shade 7.
That said, don't throw away your eyes entirely. Saturated hues near L:0.65–0.75 can look weirdly bright even when the numbers check out. Always do a final visual pass with real UI components before shipping.
If you're building glassmorphic UIs — check out the glassmorphism components collection — OKLCH is especially useful because frosted backgrounds need precise lightness control to look right against multiple underlying colors.
Wiring Tokens Into Your Component Library
Once your token system is solid in CSS, you want it available in your JS/TS layer too. The cleanest approach: generate a typed token object from your CSS variables at build time. That way autocomplete works, typos fail at compile time, and you don't have two sources of truth.
Here's a minimal typed token map in TypeScript:
export const tokens = {
color: {
bgBase: 'var(--color-bg-base)',
bgElevated: 'var(--color-bg-elevated)',
textPrimary: 'var(--color-text-primary)',
actionPrimary: 'var(--color-action-primary)',
},
} as const;
type ColorToken = typeof tokens.color;
// Usage in a component:
const Button = ({ children }: { children: React.ReactNode }) => (
<button
style={{
background: tokens.color.actionPrimary,
color: tokens.color.bgBase,
}}
>
{children}
</button>
);The as const assertion gives you literal types, so if you accidentally write tokens.color.actonPrimary TypeScript yells at you immediately. For larger teams this is non-negotiable.
Worth noting: if you're using Tailwind CSS v4, it reads CSS custom properties natively now, so your --color-action-primary token automatically becomes bg-(--color-action-primary) in utility classes. No more syncing a tailwind.config.js color object with your CSS variables — one source of truth, finally.
The gradient generator tool can help you visually check that your OKLCH-based brand gradients look right before you commit them to tokens — handy during the early palette-building phase.
Rolling This Out Without Breaking Everything
Incremental adoption is the only sane path on an existing codebase. Don't try to migrate all 847 hardcoded hex values in a single PR — you won't finish, it'll break things, and the team will lose faith in the system before it's shipped.
Start with the semantic layer for new components only. Run primitives and semantics in parallel with your old hardcoded values for one or two sprints. Then do a codemod pass: find/replace the most-used raw values with their semantic equivalents. Tools like postcss-design-tokens or a custom ast-grep rule can automate 80% of it.
That said, the *real* rollout risk isn't the code — it's alignment. If your designers are still handing off Figma files with hex values and no token annotations, you're building a system only half the team can use. Get the token names into Figma variables (Figma's 2024 token feature) and make the handoff use token names, not hex.
One more thing — document the *why*, not just the *what*. A token named --color-action-primary is self-documenting. A token named --color-blue-interactive-button-hover is a liability. Keep names role-based and terse, and write a single-page doc that explains the three-tier model to new contributors. You'll thank yourself in six months.
FAQ
You can mix — use OKLCH for your generated scales and surfaces, and keep brand colors in whatever format your brand team provides. Just convert them to OKLCH at the primitive layer so the rest of the system stays consistent.
No. With the prefers-color-scheme media query and a [data-theme] attribute override, pure CSS handles it. JS is only needed if you want a user-controlled toggle that persists to localStorage.
If a single engineer can't hold the full semantic layer in their head, it's too many. Aim for under 40 semantic tokens for most apps — if you're going over, you're likely tokenising component-level concerns that should stay in component styles.
Yes, Tailwind v4 reads CSS custom properties natively, and you can define OKLCH values directly in your CSS config. Earlier versions need a custom plugin or you define the values in your CSS layer and reference them as arbitrary values like bg-[oklch(0.55_0.22_264)].