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:
| State | Hex in the source | As oklch() | What changed | White text |
|---|---|---|---|---|
| Rest | #b54f17 | oklch(55.14% 0.1471 44.6) | The brand | 5.1:1 |
| Hover | #d9665b | oklch(64.69% 0.1467 27.41) | Lighter, and the hue turned 17 degrees toward pink | 3.5:1 |
| Active | #8a4a2a | oklch(48.04% 0.098 45.98) | Darker, and a third of the chroma gone: brown | 6.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.
--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.
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 isoklch(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).
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:
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:
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 …),lis a number from 0 to 1, not a percentage, so the darker step iscalc(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, resolvinglfrom 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 tooklch(0.551 0.147 44.6 / 0.5), andrgb(from #b54f17 r g b / 50%)tocolor(srgb 0.71 0.31 0.09 / 0.5), not torgba(). Scripts that parsergb()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:
- Space the lightness evenly. Because OKLCH lightness is close to perceived lightness, equal steps in
llook like equal steps. The ramp builder uses eleven steps from 97 percent to 17 percent, eight points apart. - Keep the hue. Every step uses the input's
h, so the scale never drifts the way hand-picked hex did. - 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.
- 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.
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:
Each rule encodes a reason:
- Hover and active move away from the label, so the label's contrast rises as the reader interacts.
--inkis derived from the label: whencontrast-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."
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 oncontrast-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.
| Background | Chosen | White text | Black text |
|---|---|---|---|
oklch(55.14% 0.1471 44.6), the store's orange | White | 5.1:1 | 4.1:1 |
oklch(62% 0.19 45), a lighter orange | Black | 3.9:1 | 5.3:1 |
oklch(60% 0.15 250), a mid blue | Black | 3.9:1 | 5.4:1 |
oklch(58% 0.12 0), the hardest case sampled | Black | 4.58:1 | 4.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.
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.
var() it is noticed after the cascade, when the earlier line is already gone.Three things do work:
@supportsaround 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 forcontrast-color():@supports (color: oklch(from red l c h))and@supports (color: contrast-color(red)). Scripts ask the same withCSS.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 itsinitial-value. The CSS Values page introduced@property; it has been Baseline since July 2024. - Not the
var()fallback.var(--hover, blue)usesblueonly when--hoveris not defined at all. A defined but useless value is still substituted, and the declaration still fails. Chrome 156 computescolor: var(--bad, blue)with--bad: notacoloras the inherited color, not blue.
| Feature | Chrome, Edge | Firefox | Safari | Baseline |
|---|---|---|---|---|
color-mix() | 111 | 113 | 16.2 | Widely available since November 2025 |
in oklch and hue methods in gradients | 111 | 127 | 16.2 | Newly available, June 2024 |
| Relative color syntax | 119, complete in 125 | 128 | 16.4, complete in 18 | Newly available, September 2024 |
contrast-color() | 147 | 146 | 26 | Newly available, April 2026 |
light-dark() with images | 150 | 150 | 27 | Newly available, September 2026 |
alpha() | 152 | 155 | 27 | Newly available, September 2026 |
color-mix() with more than two colors | No | 150 | 27 | Not 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 oklchtakes the shorter way, from hue 259 up through 360 to 59, and its middle isoklch(64% 0.162 339): a vivid pink-magenta.in oklch longer huetakes the long way, through cyan, green and yellow, and its middle isoklch(65% 0.154 157): a vivid green.increasing hueanddecreasing huefix the direction regardless of length, which matters when hues are animated.
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.
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.
| Question | Run time (CSS functions) | Build time (fixed values) |
|---|---|---|
| Changing the brand | One token, live, even per page or per user | Rebuild and redeploy |
| User themes and settings | A user's chosen accent can drive every state | Only the themes that were built |
| Old browsers | Needs a fallback tier for each newer function | Works wherever oklch() does, or emit hex too |
| Debugging | DevTools shows the formula; the computed value is a step away | The value is in the source |
| Testing contrast | Test the rules on sample inputs, or in a browser | Test every value in a unit test, as this site's theme test does |
| Hand adjustments | An exception overrides one token | Edit 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
| Concept | Key points |
|---|---|
| One source of truth | Store 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 none | Mixing 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 color | oklch(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. |
| Scales | Even OKLCH lightness steps, the input's hue, chroma easing off at the ends, out-of-gamut steps flagged or fitted. |
| State sets | Hover 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. |
| Fallbacks | A 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. |
| Gradients | Legacy 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 time | Run time for live theming and unknown colors; build time for old browsers, audited ratios and multi-platform tokens. Both together: built fallbacks, derived enhancements. |
