Design · · 6 min read
Readable text on any background: how CSS contrast-color() works
contrast-color() returns black or white, whichever contrasts more with the colour you pass it. What the spec guarantees, what it refuses to promise, and how to ship it.
When the background colour comes from a brand token, a CMS field or a colour picker, the text colour on top of it can't be a fixed value. CSS now works it out for you:
.tag {
background: var(--tag-color);
color: contrast-color(var(--tag-color));
}contrast-color() resolves to either white or black — whichever gives more contrast when the colour you passed is used as a solid background behind text. It became Baseline newly available on 10 April 2026, when Edge 147 shipped it.
Two things about it are easy to miss, and both matter before you delete your colour-pairing tokens: the function never returns anything except white or black, and the contrast algorithm is deliberately left to the browser.
The syntax
There is one argument, and it takes any <color>:
color: contrast-color(#0f766e); /* a literal */
color: contrast-color(var(--surface)); /* a custom property */
color: contrast-color(oklch(70% 0.15 200));Per CSS Color Module Level 5, the result is white or black, "whichever produces maximum color contrast for text when the input color is used as a solid background". If both produce the same contrast, the result is white.
It is valid anywhere a <color> is, including background-color and border-color — but the guarantee is written for text on a solid background, so using it elsewhere means you're on your own.
What the spec promises, and what it doesn't
This is the part to read carefully, because it sets the ceiling on what the function can do for you.
| Question | Answer in CSS Color 5 |
|---|---|
| Which colours can it return? | white or black. Nothing else. |
| Which contrast algorithm decides? | "UA-defined at this level" — the browser chooses. |
| Is any level guaranteed? | Returned colours "should still meet the WCAG 2.1 … AA large text" requirement. |
| Can I ask for a specific ratio? | Not at this level. |
Three consequences follow.
Two browsers may disagree. The spec actively advises implementers not to use the plain WCAG 2.1 contrast-ratio formula, because it "has several known issues", while still asking that results clear AA for large text. Different engines can therefore pick differently for a colour sitting near the crossover point between light and dark. Expect the odd disagreement, and don't screenshot-diff it.
The floor is AA large text, not AA body text. If you have a legal or contractual obligation to a specific ratio for specific text sizes, the function does not express that. Check the pairs you care about and keep them as tokens.
Pure white and pure black may not be your palette. Most design systems use an off-white and a near-black rather than #fff and #000. contrast-color() cannot return those, so it fits utility surfaces (tags, chips, swatches, legends) better than it fits your primary button.
The spec is also blunt about the wider point, in a note worth quoting to anyone who treats contrast as a checkbox: "Legibility is a complex topic, and sufficient color contrast is only one piece of the puzzle."
drafts.csswg.org ↗CSS Color Module Level 5: the contrast-color() functionThe normative definition, including what the browser is free to decide. Source: CSS Working GroupIt only knows the colour you hand it
The function reads one colour value. It does not look at what is actually painted behind the text, which rules out several cases people expect it to solve:
- Gradients and images.
background: linear-gradient(...)gives it nothing to read. Pass the colour the text actually sits over, or pick the worse end of the gradient yourself. - Translucency. A colour with alpha will be composited over whatever is underneath.
contrast-color()evaluates the colour as written, not the composited result. - Stacked layers. A translucent scrim over a photo is a composite the function can't see.
In all three cases the honest fix is the old one: control the surface the text sits on, then ask about that surface.
Shipping it now
The cascade gives you the fallback for free. Declare a real colour first, then the function: browsers that don't understand contrast-color() drop the second declaration and keep the first.
.tag {
background: var(--tag-color);
color: #fff; /* used by older browsers */
color: contrast-color(var(--tag-color)); /* used where supported */
}If the two branches need to differ by more than one property, test for it instead:
@supports (color: contrast-color(red)) {
.tag { /* styles that assume automatic contrast */ }
}Pick the fallback colour that is right for most of your tokens, not a neutral compromise. If nine of your ten tag colours are dark, the fallback is white.
Browser support
| Browser | Version | Released |
|---|---|---|
| Safari | 26 | 15 September 2025 |
| Firefox | 146 | 9 December 2025 |
| Chrome | 147 | 7 April 2026 |
| Edge | 147 | 10 April 2026 |
Support data from @mdn/browser-compat-data 8.1.1, correct at the time of writing. Samsung Internet has no support as of version 30, which is worth knowing if a meaningful share of your traffic runs on it — Baseline's core browser set does not include it.
contrast-color() is also a focus area of Interop 2026, so the remaining cross-browser differences are being tested and fixed this year rather than left to drift.
What the next version wants to add
Everything above describes the simplified function, which is what browsers ship. A separate, much more ambitious version is being drafted in CSS Color Module Level 6 — a document that currently carries the status "exploring" and a "Not Ready" warning, so treat it as direction, not as a plan you can schedule.
In that draft you supply your own candidate colours and a target, and the function returns the first candidate that meets it. This is the draft's own example, placeholder keywords included:
/* CSS Color 6 draft syntax — not implemented anywhere */
color: contrast-color(wheat tbd-bg wcag2(AA), bisque, darkgoldenrod, olive);The candidate list defaults to white, black if you don't provide one, target levels are expressed with a wcag2() notation, and the keywords marking whether the base colour is foreground or background are explicitly flagged as undecided in the draft. That is the version design systems actually want. It is not close.
FAQ
Does contrast-color() guarantee WCAG AA for body text?
No. The specification asks implementations to meet WCAG 2.1 AA for large text, and leaves the algorithm to the browser. For body text at a specific ratio, verify the pairs yourself.
Can it return a colour from my palette instead of white or black?
Not in the version browsers ship. Candidate colours are part of the CSS Color 6 draft, which no browser implements at the time of writing.
Does it work with a gradient or image background?
No. It evaluates the single colour you pass it. For a gradient, pass the colour at the least favourable end, or place text on a solid surface.
Is it safe to use without a fallback?
Not yet. It is Baseline newly available rather than widely available, so declare a plain colour first and let the cascade handle older browsers.
Sources
- CSS Color Module Level 5 — contrast-color() — CSS Working Group Editor's Draft, the shipped definition
- CSS Color Module Level 6 — the exploring-status draft with candidate colours and target levels
@mdn/browser-compat-data— version and release-date data, package version 8.1.1web-features— Baseline status and keystone date forcontrast-color- Interop 2026 focus areas — CSS contrast-color() focus area
More to read
- Design
Design · · 6 min read
The dialog you can open, close and style without JavaScript
A button attribute opens a modal and a pseudo-class styles it, with no script. What's Baseline, what still needs JavaScript, and the attribute Safari hasn't shipped.
- Tools
Tools · · 6 min read
Interop 2026: what the browser makers agreed to fix this year
Twenty focus areas and four investigations, chosen by the people who ship the engines. What Interop tells you that Baseline can't, and how to read the dashboard.