CSE 134B Web Client Languages
  1. Presentation
  2. Color
  3. Deriving color

Presentation · Color 04

Deriving color

A design system needs dozens of colors, but most of them are variations on a few. Store the few as tokens and let CSS compute the rest: color-mix() blends, relative color syntax adjusts one channel at a time, contrast-color() picks a readable label, and a gradient names the space it travels through. One source of truth replaces a drawer full of hand-picked hex values, and a rebrand becomes a one-line change.

Color in CSS taught the notations and made oklch() the default for tokens. This page does arithmetic with them. Every technique here works on a value the browser computes, so the page also covers what happens when a browser cannot compute it, which is where derived color most often goes wrong. Two demos run through the page: a ramp builder that turns one color into an eleven-step scale, and a state set that derives every state of a button from one token.

1. One source of truth

Ask a designer for "the brand orange, a bit darker, for hover" and you get a hex value. Ask again next month for the pressed state and you get another. Each is a reasonable judgment made by eye, on one screen, on one day, and none of them records how it relates to the brand color. That is how a style sheet ends up with forty oranges that are almost the same.

The broken store shows the result. On the discolored Trail report, SIN #2 is a button whose states were picked one hex value at a time. Converted to oklch(), which Color in CSS showed how to read, the numbers say what the eye half noticed:

The discolored store's button states, converted to oklch() and measured against white text
StateHex in the sourceAs oklch()What changedWhite text
Rest#b54f17oklch(55.14% 0.1471 44.6)The brand5.1:1
Hover#d9665boklch(64.69% 0.1467 27.41)Lighter, and the hue turned 17 degrees toward pink3.5:1
Active#8a4a2aoklch(48.04% 0.098 45.98)Darker, and a third of the chroma gone: brown6.8:1

The source's own comment admits it: "A bit lighter" went pink; "pressed" went brown. The hover state also fails the 4.5:1 minimum for its own label, because nobody measured it. And when the brand changes, these values stay behind, because nothing connects them to it.

The fix, on the fixed Trail report (COLOR FIX #2), is to state the relationship instead of its result: hover is the accent mixed 15 percent toward black, active is 30 percent. Those two sentences are the design decision; the colors follow from them, on any brand color, in either color scheme. The rest of this page is the toolkit for writing such sentences in CSS.

One token, five derived statesAt the left, one token: --accent, oklch(55.14% 0.1471 44.6), the Terra Outfitters orange. Arrows lead to five derived states, each with its rule. Hover: color-mix in oklch toward black, 15 percent, giving oklch(46.87% 0.125 44.6). Active: 30 percent toward black, oklch(38.6% 0.103 44.6). Disabled: the same lightness with 15 percent of the chroma, a warm gray. Selected: lightness 93 percent with a third of the chroma, a pale tint. Focus ring: lightness 45 percent with full chroma. Change the token and all five follow.one token--accentbrand orangeoklch(55.14% 0.1471 44.6)hovercolor-mix(in oklch, var(--accent), var(--ink) 15%)activecolor-mix(in oklch, var(--accent), var(--ink) 30%)disabled: a warm grayoklch(from var(--accent) l calc(c * 0.15) h)selected: a pale tintoklch(from var(--accent) 0.93 calc(c * 0.35) h)focus ringoklch(from var(--accent) 0.45 c h)
Each state is a rule applied to one token. Change --accent and every state follows; the hue never drifts, because no rule touches it.

2. Mixing: color-mix()

color-mix() blends two colors in a named color space and returns the result. The percentages say how much of each; leave both out for an even mix, or give one and the other gets the rest.

:root { --accent: oklch(55% 0.15 45); /* Tint and shade: mix toward white or black. */ --accent-tint: color-mix(in oklch, var(--accent), white 20%); --accent-shade: color-mix(in oklch, var(--accent), black 20%); /* Overlay: 15% of the accent over whatever is behind. */ --accent-wash: color-mix(in srgb, var(--accent) 15%, transparent); /* Soft background: the accent folded into the page surface. */ --accent-soft: color-mix(in oklch, var(--accent), var(--surface) 85%); }

The first argument, in oklch, is not decoration. A mix is a straight line between two points, and the space decides what "straight" means. Take pure blue and pure yellow, which in sRGB are opposite corners of the cube, and mix them half and half in three spaces. Chrome 156 computes:

  • in srgb: color(srgb 0.5 0.5 0.5), a flat middle gray. The red, green and blue channels average to the same value.
  • in oklab: oklab(0.71 -0.052 -0.056), which is oklch(71% 0.077 227): a lighter, muted blue-gray. The line still passes near the neutral axis, but the lightness is the plain average of the two Oklab lightnesses, which is a perceptual middle, not the average of the sRGB channels.
  • in oklch: oklch(71% 0.262 187), a vivid turquoise. In a polar space the hue travels around the wheel instead of cutting across it, and the chroma stays high. So high, in fact, that the result is outside sRGB, and the browser will gamut-map it when it draws it (Color in CSS showed how Chrome does that).
Blue and yellow, mixed half and half in four waysFour rows. Each starts with a blue swatch, oklch(45.2% 0.313 264), and ends with a yellow swatch, oklch(96.8% 0.211 110), with the 50 percent mix between them. In srgb: color(srgb 0.5 0.5 0.5), a middle gray. In oklab: oklch(71% 0.077 227), a muted blue-gray. In oklch, shorter hue, the default: oklch(71% 0.262 187), a vivid turquoise, outside sRGB, so it is drawn clipped. In oklch longer hue: oklch(71% 0.262 7), a vivid pinkish red, also outside sRGB. Same two colors, same 50 percent, four different results.blue50% mixyellowin srgbcolor(srgb 0.5 0.5 0.5): a middle grayin oklaboklch(71% 0.077 227): a muted blue-grayin oklch (shorter hue)oklch(71% 0.262 187): vivid turquoise, outside sRGBin oklch longer hueoklch(71% 0.262 7): vivid pink-red, outside sRGB
The same two colors and the same percentage give four colors. The space is part of the instruction, which is why color-mix() asks for it.

None of the four is wrong; each is the right answer to a different question. For tints and shades of one color, in oklch is the usual choice, because mixing toward white or black changes lightness and chroma while the hue stays put. For an overlay toward transparent, the space hardly matters: the mix is premultiplied, so any space gives the accent itself at reduced opacity. For mixing two unrelated hues, try two spaces and look; in oklab is the safe middle, and the current Color Level 5 draft makes it the default when the space is left out. Chrome 156 accepts that short form, but browsers from before the change reject it, so write the space.

Mixing toward a neutral: hue none

Mixing in a polar space interpolates the hue too, and that has a trap. White, black and grays have no meaningful hue. CSS Color 4 calls such a hue powerless, and when a color is converted into a polar space a powerless hue becomes missing: the mix borrows the other color's hue, so mixing toward white or black in oklch keeps the accent's hue exactly. Chrome 156 gives the same result for black and for oklch(0% 0 none), which says "no hue" explicitly.

A color that is almost neutral is a different matter. The Terra Outfitters text color, oklch(27.08% 0.0252 144.84), is a very dark gray with a slight green cast, and its hue counts. The store's builder first mixed the hover state toward --text, which sounded natural: hover gets a little closer to the text color. Measured in Chrome 156, mixing the orange 15 percent toward that green-gray turned its hue from 44.6 to 59.6, and 30 percent turned it to 74.7: fifteen and thirty degrees toward yellow. That is the drift of SIN #2, reintroduced by the fix. The published fix mixes toward a token written with a missing hue:

:root { --accent: light-dark(oklch(55.14% 0.1471 44.6), oklch(74% 0.14 50)); --on-accent: light-dark(oklch(100% 0 0), oklch(20% 0.03 50)); /* Plain values, for browsers without color-mix(). */ --accent-hover: light-dark(oklch(48.5% 0.13 45), oklch(79% 0.12 50)); --accent-active: light-dark(oklch(42% 0.11 45), oklch(84% 0.09 50)); } @supports (color: color-mix(in oklch, red, blue)) { :root { /* Black in light mode, white in dark mode, and no hue to drift toward. */ --ink: light-dark(oklch(0% 0 none), oklch(100% 0 none)); --accent-hover: color-mix(in oklch, var(--accent), var(--ink) 15%); --accent-active: color-mix(in oklch, var(--accent), var(--ink) 30%); } }

In light mode the accent is a mid orange under white text, and mixing toward black darkens it, so white text gains contrast: 5.1:1 at rest, 7.2:1 on hover, 10.1:1 when pressed. In dark mode the accent is a light orange under dark text, and mixing toward white lightens it: 7.5:1 at rest and 8.8:1 on hover. Either way each state moves away from its label. That is the rule worth keeping, and the state-set demo below makes it explicit.

Two newer details are worth knowing and not yet worth relying on. Color Level 5 lets color-mix() take more than two colors; Firefox 150 and Safari 27 support that, and Chrome 156 does not. And because color-mix() has been Baseline since May 2023 and widely available since November 2025, it is the derivation tool that needs the least guarding, though the guard above still matters for the reason section 7 explains. It tests color-mix() only, and the store's tokens also use light-dark(), which arrived a year later, in May 2024; a page that must work in 2023 browsers tests both, @supports (color: color-mix(in oklch, red, blue)) and (color: light-dark(red, red)), or gives light-dark() its own fallback, as the store does.

3. Adjusting: relative color syntax

color-mix() moves a color toward another color. Relative color syntax edits the color's own channels. Write from and an origin color inside any color function, and each channel's name becomes a variable holding the origin's value in that function's space:

:root { --accent: oklch(55% 0.16 45); --accent-darker: oklch(from var(--accent) calc(l - 0.08) c h); --accent-muted: oklch(from var(--accent) l calc(c * 0.5) h); --accent-complement: oklch(from var(--accent) l c calc(h + 180)); --accent-half: oklch(from var(--accent) l c h / 50%); --accent-on-p3: oklch(from var(--accent) l calc(c * 1.2) h); }

The browser converts the origin into the function's space first, so oklch(from #b54f17 l c h) reads a hex color as lightness, chroma and hue: Chrome 156 gives oklch(0.551 0.147 44.6). rgb(from …) exposes r, g and b; hsl(from …) exposes h, s and l; every function also exposes alpha. Any channel can be a constant, a calc(), or a function such as min() or clamp(), and the ramp builder below uses min() to cap chroma.

Three details trip people up:

  • Channels are plain numbers. Inside oklch(from …), l is a number from 0 to 1, not a percentage, so the darker step is calc(l - 0.08). calc(l - 8%) mixes a number with a percentage, and Chrome 156 rejects the whole value. Chrome's first release of the syntax, 119, had the opposite bug, resolving l from 0 to 100; it was fixed in 122.
  • Hue math wraps. calc(h + 180) on a hue of 300 gives 480, which the browser reads as 120. That is what makes the complement a one-liner. Choosing and pairing builds palettes from that kind of rotation; note that a complement found in OKLCH is not the same hue as one found in HSL, because the wheels are spaced differently.
  • The result lands in the function's space. oklch(from #b54f17 l c h / 50%) computes to oklch(0.551 0.147 44.6 / 0.5), and rgb(from #b54f17 r g b / 50%) to color(srgb 0.71 0.31 0.09 / 0.5), not to rgba(). Scripts that parse rgb() strings break on both, as Color in CSS showed.

Only the alpha: alpha()

Changing only the opacity of a token is the commonest derivation of all: a translucent backdrop, a hover wash, a shadow in the brand's hue. With relative color you have to pick a function to do it in, and the result is converted into that function's space. Color Level 5 adds alpha(from var(--x) / 50%), which changes the alpha and nothing else, and keeps the origin's own space. In Chrome 156, alpha(from #b54f17 / 50%) computes to rgba(181, 79, 23, 0.5), and the same call on an oklch() value stays oklch().

Its status is the newest on this page. Chrome shipped it in 152 (August 2026), Firefox in 155 and Safari in 27, which made it Baseline newly available on 14 September 2026, according to both MDN's compatibility data and the Web Platform Status dashboard. Any reader on a browser from before late summer 2026 does not have it. Treat it as an enhancement: oklch(from var(--x) l c h / 50%) does the same job in browsers from 2024 on, and a plain translucent value covers everything older.

How new is relative color?

Relative color syntax became Baseline on 16 September 2024, when Safari 18 joined Firefox 128 and Chrome and Edge. Chrome shipped it in 119 and fixed bugs through 125 (the lightness fix for oklch() came in 122, and hsl() and hwb() were right from 125). Safari 16.4 had an earlier version based on an older draft, whose hue math needed explicit units. Baseline "widely available" comes thirty months after the newly available date, so for relative color it arrives in March 2027. Until then, a meaningful share of readers, on older phones that no longer update, have browsers that do not understand it. That is the case for the guard in section 7.

4. From one color to a scale

A token set usually needs more than one accent and its states. It needs a tonal scale: the same hue from nearly white to nearly black, for backgrounds, borders, text and charts. Design systems name the steps 50, 100, 200 and so on to 900 or 950. Relative color syntax builds such a scale from one input, and OKLCH makes the steps behave:

  1. Space the lightness evenly. Because OKLCH lightness is close to perceived lightness, equal steps in l look like equal steps. The ramp builder uses eleven steps from 97 percent to 17 percent, eight points apart.
  2. Keep the hue. Every step uses the input's h, so the scale never drifts the way hand-picked hex did.
  3. Ease the chroma off at the ends. Near white and near black, no screen has room for much color, so a step that keeps the input's full chroma at 97 percent lightness is outside sRGB. The builder multiplies the chroma by a factor that is 1 in the middle and falls to 0.1 at the light end and 0.3 at the dark end. The curve is hand-tuned so a typical brand color, with a chroma near 0.15, stays inside sRGB at every step.
  4. Check, and flag what fails. Highly saturated inputs, and some hues more than others, still leave the gamut. The builder checks every step against sRGB and says so in words; an option lowers just those steps to the most chroma sRGB allows at that lightness and hue.
.ramp li { /* Set per step: --l is the lightness, --f the chroma factor. */ background-color: var(--fallback); } @supports (color: oklch(from red l c h)) { .ramp li { background-color: oklch(from var(--base) var(--l) calc(c * var(--f)) h); } }

A generated scale is a starting point, not a palette. The steps are even, which is what a system wants, but designers often adjust a few by hand: a little more chroma in the step used for links, a slightly warmer dark end. Choosing and pairing is about those choices. What the generated scale gives you is a predictable baseline, so each hand adjustment is a visible, deliberate exception.

5. A state set from one token

A button, a link, a tab or a row in a list needs a family of colors: its resting color, a hover and a pressed state, a disabled state, a selected state and a focus ring. Each is a relationship to the accent, so each can be a rule:

@layer tokens { /* Tier 1: plain values for the default accent. */ :root { --accent: oklch(55.14% 0.1471 44.6); --on-accent: oklch(100% 0 0); --ink: oklch(0% 0 none); --accent-hover: oklch(46.87% 0.125 44.6); --accent-active: oklch(38.6% 0.103 44.6); --accent-disabled: oklch(55.14% 0.022 44.6); --accent-selected: light-dark(oklch(93% 0.051 44.6), oklch(32% 0.051 44.6)); --focus-ring: light-dark(oklch(45% 0.1471 44.6), oklch(82% 0.1471 44.6)); } /* light-dark() arrived in 2024; older browsers get the light values. */ @supports not (color: light-dark(red, red)) { :root { --accent-selected: oklch(93% 0.051 44.6); --focus-ring: oklch(45% 0.1471 44.6); } } /* Tier 2: mixing follows any accent. */ @supports (color: color-mix(in oklch, red, blue)) { :root { --accent-hover: color-mix(in oklch, var(--accent), var(--ink) 15%); --accent-active: color-mix(in oklch, var(--accent), var(--ink) 30%); } } /* Tier 3: channel math follows any accent. */ @supports (color: oklch(from red l c h)) { :root { --accent-disabled: oklch(from var(--accent) l calc(c * 0.15) h); --accent-selected: light-dark( oklch(from var(--accent) 0.93 calc(c * 0.35) h), oklch(from var(--accent) 0.32 calc(c * 0.35) h)); --focus-ring: light-dark( oklch(from var(--accent) 0.45 c h), oklch(from var(--accent) 0.82 c h)); /* The label's opposite: black under a white label, white under black. */ --ink: oklch(from var(--on-accent) calc(1 - l) 0 none); } } @supports (color: contrast-color(red)) { :root { --on-accent: contrast-color(var(--accent)); } } }

Each rule encodes a reason:

  • Hover and active move away from the label, so the label's contrast rises as the reader interacts. --ink is derived from the label: when contrast-color() picks black text for a light accent, hover lightens instead of darkening. An earlier version of the demo mixed toward black in light mode and white in dark mode, the store's rule, with white text in both modes; in dark mode its hover fell to 3.8:1. The store's rule is right for the store, whose dark-mode accent carries dark text. The rule underneath both is the same: move away from the text.
  • Disabled keeps the lightness and drops most of the chroma, so it reads as "this button, switched off" rather than as a different button. WCAG exempts inactive controls from contrast minimums, but a reader still needs to see that the control exists and why it cannot be used, so label it ("Sold out") rather than relying on the gray.
  • Selected is a background behind ordinary text, so it is pale in light mode and dark in dark mode, with a little of the accent's chroma, and it pairs with a non-color cue such as a bar or a check mark. Page 08, Color for people, explains why color alone is not enough.
  • The focus ring must stand out from the page around it: WCAG 2.2's non-text contrast criterion asks for 3:1 against adjacent colors. A fixed lightness, dark in light mode and light in dark mode, clears that against the page for most accents (the state-set demo measures it), and the accent's hue keeps it on brand. Color in CSS showed the other good choice, currentColor.

6. Picking a label: contrast-color()

Every state above needs a text color that can be read on it. contrast-color() asks the browser to choose one. It takes a color and, in the words of CSS Color 5, "resolves to either white or black, whichever produces maximum color contrast for text when the input color is used as a solid background."

.tag { background-color: var(--tag-color); color: oklch(100% 0 0); /* every browser */ } @supports (color: contrast-color(red)) { .tag { color: contrast-color(var(--tag-color)); /* black or white */ } }

It is the right tool for colors you do not choose: tags a user colors, categories from a database, an accent picked in a settings page. It is not a contrast checker, and the specification is careful to say less than its name suggests:

  • It returns only black or white. Not your brand's near-black, not a tinted label. Future levels may add more control.
  • The algorithm is up to the browser. The specification says the "precise color contrast algorithm … is UA-defined at this level", advises browsers "to not simply use" the WCAG 2.1 ratio because of its known issues, and asks only that the result meet the WCAG ratio for large text, 3:1.
  • Mid-tones are where the choice is hard. Whatever the algorithm, a background halfway between black and white gives neither label much contrast. Under WCAG 2's formula, the best of black or white never falls below about 4.58:1, which is just above the 4.5:1 minimum for body text and well below the 7:1 enhanced level. A reader with low vision gets the minimum, not a comfortable margin.
  • The math and the eye can disagree. On a mid blue such as oklch(60% 0.15 250), WCAG 2 gives black 5.4:1 and white 3.9:1, so a WCAG-based choice is black, though many readers find white easier there. Perceptual contrast models such as APCA favor white on that blue. Jen Simmons's WebKit article on contrast-color() walks through a similar example, a blue where WCAG 2 gives black 5.45:1 and white 3.84:1.
  • It sees one solid color. A translucent background, a gradient or a photo behind the text is not part of its input, and neither is the font's size or weight.

What does a browser actually do? In Chrome 156 the choice follows WCAG 2's formula: on a gray ramp it switches from white to black text exactly where the two ratios cross, near OKLCH lightness 56.5 percent, and across 434 sampled colors at chroma 0.12 it never chose a label below 4.58:1. The WebKit article says Safari's implementation used the WCAG 2 formula too. So today the function lands just above 4.5:1 on the worst mid-tones, but that is the browsers' current choice, not the specification's promise, and it may change as better algorithms arrive.

What Chrome 156's contrast-color() chose, with both WCAG 2 ratios
BackgroundChosenWhite textBlack text
oklch(55.14% 0.1471 44.6), the store's orangeWhite5.1:14.1:1
oklch(62% 0.19 45), a lighter orangeBlack3.9:15.3:1
oklch(60% 0.15 250), a mid blueBlack3.9:15.4:1
oklch(58% 0.12 0), the hardest case sampledBlack4.58:14.59:1

So use it as a convenience with a check behind it. Let it pick the label for colors you cannot know in advance; for your own tokens, compute the ratio in a test, as this site's theme does, and choose on purpose. Where a pair must reach more than the minimum, prefer backgrounds clearly light or clearly dark, which the ramp builder's ratios show you how to find.

On support: Safari shipped contrast-color() first, in 26 (September 2025), then Firefox in 146 (December 2025) and Chrome and Edge in 147, making it Baseline newly available on 10 April 2026. Readers on browsers older than those get the fallback, which is why the white label comes first above, outside the @supports block.

7. When a derivation fails

Every derived token on this page sits behind @supports, with a plain value before it. That is not ceremony. Writing the fallback first and the new value second, the trick Color in CSS showed for oklch(), works only when the browser can reject the new value while it parses the style sheet. Custom properties break that.

A browser stores a custom property's value without checking whether it is a color; --accent-hover: oklch(from var(--accent) …) is just tokens until it is used. When background-color: var(--accent-hover) is computed and the value turns out to be meaningless to this browser, the CSS Custom Properties specification calls the declaration invalid at computed-value time. It is too late to go back to an earlier declaration: the cascade has already chosen this one. So the property behaves as unset. An inherited property such as color takes its parent's value; a non-inherited property such as background-color takes its initial value, transparent.

p { color: green; } .badge { --hover: oklch(from nonsense l c h); /* stored, not checked */ color: red; color: var(--hover); /* computed: green (inherited), not red */ background-color: blue; background-color: var(--hover); /* computed: transparent, not blue */ }

This is a bad sample, on purpose: the "fallback" lines are dead code. Chrome 156 computes the badge's text as the parent's green and its background as transparent, exactly as the specification says. Written directly, without a custom property, color: red; color: oklch(from nonsense l c h) keeps red, because the second declaration fails to parse and is thrown away. The difference is when the failure is noticed.

Why a fallback works when written directly and fails through var()Two columns. Left, written directly: color: red; then color: oklch(from nonsense l c h). At parse time the second declaration is invalid and dropped, so the cascade keeps color: red. Result: red. Right, through a custom property: --x: oklch(from nonsense l c h); color: red; color: var(--x). At parse time, a value with var() is accepted without checking. The cascade picks the last declaration, color: var(--x). At computed-value time the substituted value is invalid, the declaration is invalid at computed-value time, and the property acts as unset: inherited for color, the initial value for background-color. Result: the parent's color, not red. The fix: @supports, or a registered @property with an initial value.Written directlyThrough a custom propertycolor: red;color: oklch(from nonsense l c h);--x: oklch(from nonsense l c h);color: red;color: var(--x);parse time: the new value is invalid,so that declaration is droppedparse time: var() is not checked,so the last declaration wins the cascadethe cascade keeps the line beforeresult: redcomputed-value time: invalid, so unsetresult: inherited or initial, not redThe fix: put the newer value behind @supports, or register the property with @property and an initial-value.
A fallback works only if the failure is noticed while parsing. Through var() it is noticed after the cascade, when the earlier line is already gone.

Three things do work:

  • @supports around the derived tokens, with plain values defined outside it, as in every example on this page. The browser asks the question once, at parse time, and a browser that answers no never sees the derived values. This is the guard for relative color and for contrast-color(): @supports (color: oklch(from red l c h)) and @supports (color: contrast-color(red)). Scripts ask the same with CSS.supports('color', 'contrast-color(red)'), and both demos on this page use it to say what your browser did.
  • A registered custom property. @property --accent-hover { syntax: "<color>"; inherits: false; initial-value: oklch(46.87% 0.125 44.6); } gives the token a type and a default. A value that is not a color then falls back to that initial value: in Chrome 156, a registered color property set to nonsense computes to its initial-value. The CSS Values page introduced @property; it has been Baseline since July 2024.
  • Not the var() fallback. var(--hover, blue) uses blue only when --hover is not defined at all. A defined but useless value is still substituted, and the declaration still fails. Chrome 156 computes color: var(--bad, blue) with --bad: notacolor as the inherited color, not blue.
Which derivations need a guard, as of October 2026 (MDN compatibility data and the Web Platform Status dashboard)
FeatureChrome, EdgeFirefoxSafariBaseline
color-mix()11111316.2Widely available since November 2025
in oklch and hue methods in gradients11112716.2Newly available, June 2024
Relative color syntax119, complete in 12512816.4, complete in 18Newly available, September 2024
contrast-color()14714626Newly available, April 2026
light-dark() with images15015027Newly available, September 2026
alpha()15215527Newly available, September 2026
color-mix() with more than two colorsNo15027Not yet

The table has one row that is not a color function: light-dark(), which Light and dark teaches for colors, now also accepts images (Images in both schemes), so a background image can switch with the scheme. It reached every engine in September 2026 and belongs behind the same kind of guard: @supports (background-image: light-dark(url(a.png), url(b.png))).

8. Gradients name their space

A gradient is a mix repeated at every pixel, so everything in section 2 applies to it, and CSS lets a gradient name its space in the same way: linear-gradient(in oklch, …). There is one twist, and it is the reason the broken store's gradient looks the way it does.

CSS Images 4 says a gradient with no space named interpolates in Oklab. But CSS Color 4 adds an exception: "user agents must handle interpolation between legacy sRGB color formats (hex colors, named colors, rgb(), hsl() or hwb() …) in gamma-encoded sRGB space. This provides Web compatibility." Nearly every gradient written before 2023 used legacy colors and was designed to look the way sRGB blending looks, so browsers keep doing that for them. A gradient between two hex values is still an sRGB gradient today. Writing one stop as oklch(), or naming a space, opts out.

That is the broken store's SIN #4, a band from winter blue to summer orange on the discolored Trail report: linear-gradient(90deg, #2f6fd0, #f08a24). The two colors are nearly opposite on the hue wheel, so the straight line between them in sRGB passes close to the gray axis. Measured in Chrome 156, the middle pixel is #907d7a, oklch(60.6% 0.024 28): a gray with a faint pink cast, where the designer imagined something bright.

Switching to Oklab does not save this gradient, which surprises people. Chrome 156 draws the same two colors in oklab with a middle of oklch(64% 0.029 336): lighter and a little more even, but just as gray, because Oklab is also rectangular and the straight line still cuts through the center. Only a polar space keeps the chroma up, and then the question is which way round the wheel to go:

  • in oklch takes the shorter way, from hue 259 up through 360 to 59, and its middle is oklch(64% 0.162 339): a vivid pink-magenta.
  • in oklch longer hue takes the long way, through cyan, green and yellow, and its middle is oklch(65% 0.154 157): a vivid green.
  • increasing hue and decreasing hue fix the direction regardless of length, which matters when hues are animated.
Three routes from winter blue to summer orangeAn OKLCH hue wheel seen from above. Blue, hue 259, sits at the bottom of the rim; orange, hue 59, at the top right. A straight chord joins them through the gray center: that is interpolation in sRGB, the default for hex stops, and in Oklab; its midpoint, measured in Chrome 156, is oklch(60.6% 0.024 28), gray. A short arc on the right goes through hue 339: in oklch, shorter hue; its midpoint is oklch(64.2% 0.162 339), pink-magenta. A long arc on the left goes through hue 157: in oklch longer hue, the fixed store's choice; its midpoint is oklch(64.7% 0.154 157), green.blue, hue 259orange, hue 59grayStraight through the middlein srgb (hex stops), in oklabmiddle: oklch(60.6% 0.024 28), grayShort arc, rightin oklchmiddle: oklch(64.2% 0.162 339), pinkLong arc, left (dashed)in oklch longer huemiddle: oklch(64.7% 0.154 157), green
Rectangular spaces cut across the wheel, through gray; polar spaces go around it. Which way round is a design decision, and longer hue makes it.

The fixed store (COLOR FIX #4) chose the long way, so winter passes through spring and summer on its way to autumn: linear-gradient(in oklch longer hue 90deg, oklch(56% 0.16 258), oklch(72% 0.16 55)), whose middle Chrome 156 draws as a bright green. The choice is about meaning as much as color: a gradient that tells the seasons should pass through green, not through pink.

.seasons { background-image: linear-gradient(90deg, #2f6fd0, #f08a24); } .seasons { background-image: linear-gradient(in oklch longer hue 90deg, oklch(56% 0.16 258), oklch(72% 0.16 55)); }

Polar interpolation has its own risk: like the blue and yellow mix, the middle of an OKLCH gradient can be outside sRGB, and the browser maps it in. Page 02's gradient-spaces demo draws one pair of colors in every space side by side, so you can see where each one goes. For gradients behind text, the band's palest and darkest points both need their contrast checked against the text; page 08, Color for people, covers text over images and gradients.

9. Run time or build time?

Everything on this page derives colors in the browser, at run time. The alternative is to derive them at build time: a script, a design-token tool or a preprocessor computes every step and state once and writes plain oklch() values into the style sheet. The ramp builder exports both forms, and they are worth comparing honestly.

Deriving at run time and at build time
QuestionRun time (CSS functions)Build time (fixed values)
Changing the brandOne token, live, even per page or per userRebuild and redeploy
User themes and settingsA user's chosen accent can drive every stateOnly the themes that were built
Old browsersNeeds a fallback tier for each newer functionWorks wherever oklch() does, or emit hex too
DebuggingDevTools shows the formula; the computed value is a step awayThe value is in the source
Testing contrastTest the rules on sample inputs, or in a browserTest every value in a unit test, as this site's theme test does
Hand adjustmentsAn exception overrides one tokenEdit the generated value, or the generator

Most sites can derive at run time now. The functions that matter most, color-mix() and oklch(), are widely available, relative color is close, and the tiered fallback costs a few lines. Run time is the clear winner when colors are not known in advance: a user picks a theme color, a product has a color per category, a white-label product takes each client's brand.

Build time is the safer call in a few situations. When a site must look identical in browsers from before 2024, fixed values remove a whole class of failure. When every pair needs a recorded, audited contrast ratio, fixed values can be tested exhaustively in CI rather than by sampling. And when a design system is consumed by native apps as well as the web, the tokens usually live in a neutral format and are generated for each platform; the W3C Design Tokens Community Group's format exists for exactly that. A common middle path does both: the build computes plain values for the fallback tier, and the style sheet derives the live values above them, which is exactly what the ramp builder's "derived" export does.

Either way, the principle is the one this page started with. The relationship is the design decision; write it down once, in a form a machine can apply, and test what it produces.

10. Summary

Deriving color, in brief
ConceptKey points
One source of truthStore a few base tokens and derive the rest by rule. Hand-picked hex states drift in hue and contrast and stay behind when the brand changes (the discolored store's SIN #2: hover 17 degrees toward pink and 3.5:1).
color-mix()Blends two colors in a named space; the space changes the result. Blue and yellow give gray in sRGB, blue-gray in Oklab, turquoise in OKLCH. Baseline widely available.
Hue noneMixing toward a neutral with a missing hue keeps the accent's hue. A near-neutral with a slight cast pulls the hue: 15 degrees per 15 percent toward the store's green-gray text.
Relative coloroklch(from var(--x) calc(l - 0.08) c h): channels are plain numbers, hue wraps, the result lands in the function's space. Baseline since September 2024; widely available in March 2027.
alpha()Changes only the alpha and keeps the origin's space. Baseline newly available since 14 September 2026: guard it.
ScalesEven OKLCH lightness steps, the input's hue, chroma easing off at the ends, out-of-gamut steps flagged or fitted.
State setsHover and active move away from the label; disabled drops chroma; selected is a pale or dark tint with a non-color cue; the focus ring has a fixed lightness for 3:1 against the page.
contrast-color()Black or white, by a browser-defined algorithm whose result should reach 3:1. Chrome and Safari use WCAG 2 today, so mid-tones get about 4.6:1 at best. A convenience, not a compliance check. Baseline since April 2026.
FallbacksA bad value through var() is invalid at computed-value time: the property becomes inherited or initial, not the line before. Guard with @supports or a registered @property; the var() fallback does not help.
GradientsLegacy sRGB stops interpolate in sRGB; others default to Oklab. Near-complementary stops go gray in both. in oklch keeps chroma; longer hue picks the other way round.
Run or build timeRun time for live theming and unknown colors; build time for old browsers, audited ratios and multi-platform tokens. Both together: built fallbacks, derived enhancements.