CSS contrast-color(): Safer Dynamic Text Colours


contrast-color() is now a practical way to keep text legible when a component’s background is supplied by a design token, a CMS, or a user choice. It lets the browser choose black or white against an input colour, removing a fragile list of hand-maintained foreground/background pairs. It is Baseline Newly Available in 2026, but it is a guardrail—not a substitute for choosing accessible brand colours and testing real component states.

TL;DR

Use contrast-color() for the simple, common case where a colour can change at runtime and the foreground only needs to be black or white. Put a readable fallback first, progressively enhance with @supports, and test small text, disabled states, gradients, overlays, and old browsers. The function’s deliberately narrow output is the reason it is useful: it makes a component’s contrast decision explicit and local.

Contents

  • What does contrast-color() do?
  • Why are design tokens a good use case?
  • How do you ship it safely?
  • Where does it fall short for accessibility?
  • How does it fit with modern CSS colour tools?
  • A production checklist

What does contrast-color() do?

contrast-color(<color>) accepts a CSS colour and returns either black or white, whichever has the greater contrast with that input. The current CSS Color Module Level 5 draft defines it as part of a broader set of modern colour features; MDN’s reference notes that it is newly available across the latest browsers as of 2026.

That small API solves a recurring design-system problem. A badge, button, or status chip often receives its fill from a custom property:

css
.status-chip {
  --chip-fill: oklch(62% 0.18 250);

  background: var(--chip-fill);
  color: contrast-color(var(--chip-fill));
  border: 1px solid rgb(0 0 0 / 12%);
}

The browser evaluates the final --chip-fill, then selects the stronger of black or white for the text. That matters when the same component is driven by a CMS palette, account-level branding, or an app state that changes after the CSS was authored.

It is not a general “make this accessible” function. It does not invent a tinted foreground, adjust the background, or promise that every font size is readable. Its value is reducing an easy-to-miss pairing decision without hiding the limitation.

Why are design tokens a good use case?

Design tokens work best when a component owns the relationship between its inputs and its visual contract. A token such as --chip-fill names the input; contrast-color() declares the foreground rule beside it. No JavaScript needs to inspect a computed colour, and no component has to carry a duplicate --chip-text token just to cover the black-or-white case.

This is especially useful in multi-theme products. A semantic token can point to entirely different colours per scheme while the component stays unchanged:

css
:root {
  --notice-fill: oklch(88% 0.12 90);
}

@media (prefers-color-scheme: dark) {
  :root {
    --notice-fill: oklch(45% 0.11 90);
  }
}

.notice {
  background-color: var(--notice-fill);
  color: contrast-color(var(--notice-fill));
}

This complements, rather than replaces, the conditional token patterns in CSS if(). Use if() when the system needs to select between known semantic values. Use contrast-color() when the browser should decide the black/white foreground for a resolved colour. Keeping those concerns separate makes token sets easier to audit.

The feature also makes user-selected colours less error-prone. If a profile editor exposes a colour picker, the preview can remain readable without a client-side luminance helper. The application should still validate or constrain choices if its brand requires a particular appearance; automatic contrast is a safety net, not a design approval workflow.

How do you ship contrast-color() safely today?

The direct answer: write a conservative fallback, then override it in a feature query. A browser that does not understand the function will retain the fallback declaration; a supporting browser gets the correct result for the live token.

css
.project-label {
  --label-fill: #1967d2;

  background: var(--label-fill);
  color: #fff; /* fallback chosen for this token’s default range */
}

@supports (color: contrast-color(red)) {
  .project-label {
    color: contrast-color(var(--label-fill));
  }
}

Do not use a feature query as an excuse to make the fallback unreadable. The fallback should work for the set of colours older clients can receive. If fills are truly arbitrary, consider a server-side validation rule or retain the existing JavaScript helper for the legacy bundle.

Test the component as a matrix, not just a perfect default screenshot:

  1. Test the lightest, darkest, and most saturated tokens.
  2. Test at the smallest supported font size and weight.
  3. Test hover, focus-visible, disabled, selected, and loading states.
  4. Test in the oldest browser versions your analytics still support.
  5. Test the actual painted background—not a token hidden beneath an overlay or image.

The final point is easy to overlook. A semi-transparent overlay, a gradient, or a background image changes what the reader sees, but the function reasons about the supplied colour. For complex surfaces, choose the foreground intentionally or place text on a solid scrim.

Where does contrast-color() fall short for accessibility?

contrast-color() only chooses black or white. On a mid-tone colour, neither candidate may create a dependable result for small text. MDN explicitly warns that a result with the greater contrast can still be difficult to read and recommends using light or dark input colours for this function. In other words, “greater” does not always mean “good enough.”

Treat the feature as a component-level heuristic and keep accessibility verification in the delivery process. The WCAG 2 contrast guidance remains the standard for judging normal text, large text, and non-text UI. Automated checks can catch regressions, but a visual review is essential where colour communicates status, focus, or an error.

There is another constraint: contrast is only one accessibility dimension. A red/green status system needs an icon, label, pattern, or other redundant cue. contrast-color() can help its label remain legible; it cannot make a colour-only meaning understandable.

For arbitrary brand inputs, a stronger architecture is often to maintain approved ranges. For example, offer dark and light brand swatches, provide a separately chosen text token for exceptional colours, and reject combinations that fail your acceptance criteria. That is similar in spirit to placing a browser guardrail around unsafe DOM sinks with Trusted Types: the guardrail narrows a failure mode, while the product still needs a deliberate policy.

How does it fit with modern CSS colour tools?

The real payoff arrives when contrast-color() is part of a token pipeline instead of a one-off trick. Modern CSS can derive a state colour from a base token, then assign a legible black-or-white foreground without duplicating the palette.

css
:root {
  --brand: oklch(58% 0.19 255);
  --brand-hover: oklch(from var(--brand) calc(l - 0.08) c h);
}

.cta {
  background: var(--brand);
  color: contrast-color(var(--brand));
}

.cta:hover {
  background: var(--brand-hover);
  color: contrast-color(var(--brand-hover));
}

Relative colour syntax is useful here because it derives channels from an originating colour rather than forcing teams to maintain a parallel HSL or RGB token list. It does not make every transformation accessible, so re-run the contrast decision for each derived state as the example does.

Keep state styling native when possible. The same preference for expressing relationships in CSS appears in CSS sibling functions: fewer imperative branches means fewer states that can drift apart. But native CSS is not automatically simpler if it obscures product rules. Name the token, document its permitted range, and test the resolved result.

A production checklist for dynamic component colours

  • Use contrast-color() only where black or white is an acceptable foreground.
  • Start with a tested fallback and layer the function in @supports.
  • Keep text away from ambiguous mid-tone fills; use an approved light/dark palette.
  • Recalculate for hover and active fills when those fills are derived.
  • Measure actual rendered combinations, including opacity and imagery.
  • Keep non-colour status cues and visible focus indicators.
  • Include contrast checks in component tests and design review.

FAQ

Does contrast-color() guarantee WCAG AA contrast?

No. It returns whichever of black or white has greater contrast with the supplied colour, but a mid-tone background can still be unsuitable for small text. Check the final rendered pair against the applicable WCAG criterion.

Can contrast-color() return a custom brand colour?

No. The current function chooses black or white. If a design needs a tinted foreground, define and test that foreground deliberately rather than treating this function as a palette generator.

Should I remove my JavaScript contrast helper immediately?

Only for components whose needs match this function and whose browser support policy permits progressive enhancement. Keep a fallback or validation path for legacy browsers and arbitrary user-supplied colours.

contrast-color() is a modest feature with an unusually practical outcome: it turns a repeated, error-prone token pairing into a browser decision while leaving the real accessibility policy visible. That is exactly the kind of platform capability worth adopting carefully.

Frequently Asked Questions

Does contrast-color() guarantee WCAG AA contrast?

No. It selects the stronger of black and white, but mid-tone backgrounds can still fail readability requirements.

Can contrast-color() return a brand colour?

No. It returns black or white; custom foreground colours need deliberate token design and testing.

Do I still need a fallback?

Yes. Use a tested fallback and progressive enhancement for browsers that do not support the function.