Web Developmentguide12 min read

Building Color Ramps With color-mix()

One OKLCH anchor plus color-mix() can produce a 50–950 ramp in CSS—if you mix in the right space and plan fallbacks.

Your design system JSON had one brand blue: oklch(0.58 0.17 250). Engineering needed nine steps for Tailwind-style utilities. The old pipeline ran a Style Dictionary transform that emitted --blue-50 through --blue-950 as static hex. Every rebrand required regenerating the transform, re-running CI, and diffing four hundred lines of CSS. When the brand team nudged chroma by 0.02, half the hover states looked wrong because the transform mixed in sRGB. The preprocessor script was not wrong in the sense of throwing errors. It was wrong in the sense of encoding a color geometry that made yellow ramps scream and blue ramps whisper at the same nominal step.

The fix is not another build step that multiplies static values. It is color-mix(), a function from CSS Color Module Level 5 that blends two colors in a specified color space at computed-value time. Mix in oklch, anchor on one token, and derive a full ramp in the cascade. Change the anchor and the ramp tracks without a transform script gathering dust in the token repository. The browser resolves var() references, converts operands to the mix space, interpolates, and returns a computed color. Runtime theming becomes possible: a dark theme anchor update propagates through every mixed step without waiting for a release train.

This guide explains why mix space matters, how percentage weights behave in OKLCH, how a team rebuilt a ramp pipeline after a failed sRGB transform, and where @supports fallbacks keep enterprise WebViews from inheriting broken hovers. The goal is a maintainable 50–950 scale from one signed brand coordinate, not a clever trick that collapses the first time someone mixes toward transparent on a gray canvas.

Why static ramps fracture when brand coordinates shift

Design systems that store nine hex values per hue are not incorrect. They are brittle. Each hex is a snapshot of a decision made at export time against a specific background assumption, a specific display, and a specific interpolation space used by the tool that generated the ramp. When the brand anchor moves, every snapshot must be regenerated. If the regeneration script mixes in sRGB, the new snapshots will look evenly spaced in DevTools and unevenly spaced to human eyes because sRGB interpolation cuts through non-perceptual coordinates. Björn Ottosson’s Oklab and the cylindrical oklch() form documented in CSS Color Module Level 4 exist precisely because equal steps in naive RGB or HSL space are not equal steps in perceived lightness.

color-mix() moves the derivation into the cascade. You store one anchor at step 500, the approved brand mid-tone in OKLCH. Light steps become mixes toward white or toward a surface token. Dark steps become mixes toward black. The percentages are mix weights, not OKLCH lightness values, which is a common confusion that sends teams back to static hex when their 50% mix does not land at L = 0.50. Calibration happens by inspection and contrast verification, not by assuming numeric equality between weight and lightness.

The approach pairs naturally with Design Tokens Color Module 2025.10, which encourages storing oklch components in JSON while allowing translators like Style Dictionary and Terrazzo to emit CSS. You can keep the JSON small with a single anchor per hue and let CSS derive the ramp at build or runtime. You can also pre-compute mixed values in translation if your consumers include email clients that will never support color-mix(). The architecture choice depends on your analytics tail, not on dogma about where mixing must live.

Teams sometimes reject runtime mixing because they fear performance or because designers want to see every step in Figma. Both concerns are valid and solvable. Mixed ramps can be mirrored into design tools as read-only reference swatches while the source of truth remains the anchor plus mix recipe. Performance cost of color-mix() on a dozen custom properties is negligible on modern browsers. The real cost is support gaps in older WebViews, which @supports fallbacks address without abandoning the DRY cascade for current clients.

The mechanics of color-mix in OKLCH space

The syntax, per MDN, blends two colors in a chosen interpolation space. For perceptual ramps, that space should be oklch or oklab, not the default sRGB path that older examples still use. Adam Argyle’s coverage of color-mix emphasizes choosing the interpolation space deliberately because default sRGB mixes are a common footgun, especially in blues and purples where hue drifts toward gray mud at midpoints.

color-mix(in <color-space>, <color1> <percentage>, <color2>)

The percentage is the weight of the first color. The second receives the remainder. color-mix(in oklch, var(--brand) 30%, white) yields thirty percent brand and seventy percent white along an OKLCH interpolation path that preserves hue more faithfully than an sRGB mix would. You can omit percentages for a fifty-fifty split, or specify both operands with percentages that sum to one hundred percent as defined in the Color 5 specification.

For ramps, you typically fix one endpoint and sweep the percentage across steps. Tints toward white use decreasing brand weight as you approach step 50. Shades toward black use decreasing brand weight as you approach step 950. The template is not universal. Chroma-heavy anchors in the yellow-orange family may need steeper tint curves. High-chroma blues may clip if step 50 is too white without manual taper at the extremes. Hybrid approaches are normal: mix for steps 100 through 900, hand-tune 50 and 950 where automated mixes look fluorescent or muddy.

Mixing toward transparent is valid and useful for glass overlays, but semi-transparent mixes are layers, not opaque tokens. The computed color depends on what sits beneath. For design-system ramps intended for text contrast verification, prefer opaque mixes toward white, black, or a surface token. When you need both a solid step and a translucent variant, derive the translucent version from the solid step rather than mixing brand directly toward transparent on a token that will later sit on unpredictable backgrounds.

:root {
  --brand-500: oklch(0.58 0.17 250);
  --brand-50:  color-mix(in oklch, var(--brand-500) 8%, white);
  --brand-100: color-mix(in oklch, var(--brand-500) 14%, white);
  --brand-600: color-mix(in oklch, var(--brand-500) 78%, black);
  --brand-950: color-mix(in oklch, var(--brand-500) 20%, black);
}

Change --brand-500 once and every mixed step tracks. That is the maintenance win. The calibration work is choosing percentages that look perceptually even and pass contrast on the pairs your components actually use, such as step 700 text on step 50 tinted panels or step 500 fills behind white button labels.

Rebuilding a ramp pipeline after the sRGB transform failed

The fracture happened at a fintech startup that had migrated its primitives to OKLCH in Figma and JSON but still relied on a legacy Style Dictionary transform written when the repo thought in hex. The transform took a single brand anchor per hue and emitted nine static custom properties by mixing in sRGB toward white and black. The pipeline had worked well enough when the brand was a restrained navy with low chroma. It failed visibly when the company refreshed toward a brighter cobalt with higher chroma and a secondary hue in the teal range.

The first symptom was hover states. Buttons used step 600 as hover on step 500 fills. After the rebrand, cobalt hovers looked washed out, as if someone had lowered opacity instead of darkening. Teal hovers looked nearly identical to the base fill. The transform had not changed. The anchor had. sRGB mixing does not darken all hues by the same perceived amount at the same weight. Design filed bugs against engineering. Engineering pointed at the token export. Design-ops opened the transform and saw correct-looking hex diffs with wrong-looking swatches.

The second symptom appeared in data visualization. The product used tinted surfaces derived from brand ramps for chart backgrounds and chip fills. Yellow-orange accents in the new brand system looked neon at step 100 while blue step 100 looked barely tinted. On dashboards with four chart regions visible simultaneously, the imbalance made the UI feel like two different design languages had been stitched together. A designer spent an afternoon hand-tuning Figma swatches to match perception, but the CSS still came from the sRGB transform until someone diffed computed OKLCH in Chrome DevTools and saw the steps were not evenly spaced in lightness despite looking orderly in hex.

The rebuild started with a decision to mix in OKLCH at runtime in CSS, keeping only anchors in DTCG JSON. The team picked step 500 as the signed anchor for each hue. They generated an initial percentage table from a reference template, deployed to a staging environment, and held a review session with physical sticky notes on a wall monitor, which sounds low-tech but surfaces arguments that Slack threads hide. They adjusted mix weights until steps 100 through 900 felt even against a hand-tuned reference ramp from the old system that designers trusted. Steps 50 and 950 were hand-authored OKLCH values where mixes overshot chroma caps defined in brand guidelines.

Dark mode required a second anchor at 500 for several hues because perceptual weight on dark surfaces is not the same as perceptual weight on light surfaces. The team chose strategy B from their internal doc: keep identical mix percentages, swap anchors and surface endpoints. Document both anchors in JSON. Do not mix toward literal white and black in dark theme; mix toward the dark surface token so tints do not glow like LEDs on charcoal backgrounds.

Rollout was staged behind @supports blocks with pre-computed hex fallbacks for older WebViews still present in enterprise analytics. Email templates continued to receive computed hex from a small Node script that ran the same OKLCH mix math at build time, because HTML email does not support color-mix(). The script was fifty lines, not five hundred. The CSS cascade stayed DRY for browsers that could mix. Within two sprints, the hover bugs closed without another transform rewrite when marketing asked for a chroma nudge on cobalt. Engineering changed one anchor. The ramp followed.

The lesson the team documented internally was not that static ramps are evil. Static ramps are fine when export tooling mixes in a perceptual space and when rebrand frequency is low. The lesson was that mixing space is part of the brand contract. If the contract says OKLCH in tokens, mixing in sRGB at export is a silent violation. color-mix(in oklch, …) makes the violation visible immediately because the cascade and the token authoring space finally agree.

Dark themes, fallbacks, and where mixes stop being enough

Dark mode exposes every lazy assumption about white and black as universal endpoints. Mixing brand toward white for tints on a light surface works until the surface becomes a near-black OKLCH coordinate with a faint hue tint. Then mixes toward literal white produce glowing panels that look like modal overlays rather than subtle tinted regions. Mixing toward --surface instead of white keeps tints anchored to the environment the token will inhabit. The same percentage table can often survive if the surface token changes per theme while the mix recipe stays constant.

:root {
  --surface: oklch(0.98 0.01 260);
  --brand-500: oklch(0.58 0.17 250);
  --brand-100: color-mix(in oklch, var(--brand-500) 20%, var(--surface));
}

[data-theme="dark"] {
  --surface: oklch(0.18 0.02 260);
  --brand-500: oklch(0.72 0.14 250);
  --brand-100: color-mix(in oklch, var(--brand-500) 20%, var(--surface));
}

@supports fallbacks deserve first-class treatment rather than a footnote. Place static hex or pre-mixed values first in :root, then override inside a support query that probes a valid mix expression. Supported browsers take the mixed ramp. Legacy WebViews keep usable, if less maintainable, colors until analytics justify dropping them.

:root {
  --brand-100: #e8eef9;
  --brand-600: #2f4eb8;
  --brand-500: oklch(0.58 0.17 250);
}

@supports (background: color-mix(in oklch, white, black)) {
  :root {
    --brand-100: color-mix(in oklch, var(--brand-500) 14%, white);
    --brand-600: color-mix(in oklch, var(--brand-500) 78%, black);
  }
}

Pre-compute fallback hex from the same OKLCH anchor using Color 4 algorithms. Do not guess hex from a screenshot. Guessed fallbacks drift hue on teals and purples where human memory is unreliable.

color-mix() and relative color syntax solve different problems. Mixing is ideal for ramps toward endpoints. Relative oklch(from …) syntax is ideal for fine-grained hovers that adjust a single channel by a delta. A hover implemented as color-mix(in oklch, var(--brand-500) 85%, black) follows interpolation geometry. A hover implemented as oklch(from var(--brand-500) calc(l - 0.06) c h) adjusts lightness directly. They are not identical. Pick one convention per system and document it, as discussed in Relative Colors in CSS. Tailwind v4 @theme blocks consume custom properties directly, so a mixed ramp plugs into --color-brand-500: var(--brand-500) without framework friction.

Mixes stop being enough when you need guaranteed contrast on every step against every surface, when brand guidelines cap chroma at specific steps with legal language, or when regulators expect documented spectrophotometric matches rather than perceptual approximations. In those cases, hand-tuned steps or build-time verification against a contrast matrix still belong in the pipeline. color-mix() reduces duplication. It does not replace judgment at the extremes or sign-off on pairs that matter for accessibility compliance.

Even perceptually even ramps can fail WCAG on specific pairs. Step 400 text over step 50 panels is a common trip point. Verify pairs after calibration, not only the anchor. Store percentages as custom properties if you want self-documenting token sheets, but remember mix weights are not lightness coordinates. The anchor at 500 is a design decision, not the mathematical midpoint of the mix table.

One anchor, one interpolation space, nine steps that track when brand blue shifts greener without a transform script waiting to be remembered during the next rebrand. That is the practical promise of color-mix() in OKLCH. The science behind the space is public in Ottosson’s writing. The syntax is in browsers. The remaining work is calibration and fallbacks, which are engineering habits more than spec gaps.

When your JSON holds a single OKLCH coordinate per hue and your CSS derives the rest in oklch, you have aligned authoring, computation, and perception along the same axis. The next time brand shifts chroma by two points, you will find out on staging in minutes, not after a quarter of bug reports about hovers that lie.