Why color-contrast() Matters for Theming

Accessibility requirements are easy to enforce when they're structural: alt text on images, label elements paired with form inputs. Color contrast is different. It depends on the specific shades a team chooses, and when a design system supports custom themes, those choices fall outside the system's control.

The CSS color-contrast() function, currently part of the Color Module 5 specification, offers a way to bring that enforcement back into code. The function takes a base color and a list of candidate colors, then returns the candidate with the highest contrast ratio against the base.

A pseudo-code example of the color-contrast parameters and syntax
CSS color-contrast requires a base color and a color list. (Large preview)

At the time of writing, the feature is experimental and only available in the Safari Technology Preview browser, toggleable under Develop and Experimental Features menus. The demos discussed here require that browser with the feature enabled.

Setting Text Colors Dynamically

A straightforward use case is deriving a readable text color from a background. Define a custom property for the background, pass it as the base color to color-contrast(), and let the function pick the right text shade from a list of candidates.

article {
  --article-bg: #222;

  background: var(--article-bg);
  color: color-contrast(var(--article-bg) vs #FFF, #000);
}

With the --article-bg custom property set to #222, that dark grey becomes the base color. The function compares it against the values in the color list and returns the one with the greatest contrast.

Base ColorColor ListContrast Ratio
#222#FFF15.9
#222#0001.31

The result: the article's text color renders as #FFF. The same pattern extends to related elements. Chain color-contrast() calls by feeding the result of one as the base color of another, such as deriving a ::selection background from the already-computed text color.

article {
  --article-bg: #222;
  --article-color: color-contrast(var(--article-bg) vs #FFF, #000);

  background: var(--article-bg);
  color: var(--article-color);

  ::selection {
    background: color-contrast(var(--article-color) vs #FFF, #000);
  }
}

See the Pen [CSS Color-Contrast() - Text and ::selection Demo [forked]](https://codepen.io/smashingmag/pen/ZErWwqy) by Daniel Yuschick.

See the Pen CSS Color-Contrast() - Text and ::selection Demo [forked] by Daniel Yuschick.
Results of using color-contrast() for text and ::selection colors

The function handles mixed color formats within a single invocation, so a color list can combine HEX codes with other supported formats without issue.

article {
  --article-bg: rgb(34, 34, 34);
  --article-color: color-contrast(var(--article-bg) vs hsl(0,0%,100%), black);

  background: var(--article-bg);
  color: var(--article-color);

  ::selection {
    background: color-contrast(var(--article-color) vs hsl(0,0%,100%), black);
  }
}

Context-Aware Focus Indicators

Text and background colors tend to be static once rendered. Interactive elements are where color-contrast() becomes more interesting, particularly for focus indicators. Keyboard navigation stops at a variety of elements — links, buttons, inputs — and each may need a distinct focus style to remain visible against its surroundings.

:root {
  --body-bg: #131e25;
}

button {
  --btn-bg: #ffba76;
  --btn-color: color-contrast(var(--btn-bg) vs #fff, #000);

  background: var(--btn-bg);
  color: var(--btn-color);

  &:hover {
    --btn-bg: #b15900;
  }

  &:focus {
    --color-list: var(--btn-bg), var(--btn-color), #bbb, #555;
    box-shadow: 0 0 1px 3px
      color-contrast(var(--body-bg) vs var(--color-list));
  }
}

In this example, the --btn-bg custom property drives both the button's appearance and its text color. When --btn-bg changes on :hover, the --btn-color custom property recomputes automatically — no manual pairing of background and foreground shades needed.

The :focus state goes further by using --body-bg as the base color, comparing it against the button's own styles. That produces focus indicators that are sensitive to where the element actually sits on the page. If the default focus styles lack sufficient contrast against the element's background, a higher-contrast color from the list takes over, with safe fallbacks available in the same list.

See the Pen [CSS Color-Contrast() - Button and :focus Demo [forked]](https://codepen.io/smashingmag/pen/JjpXxVB) by Daniel Yuschick.

See the Pen CSS Color-Contrast() - Button and :focus Demo [forked] by Daniel Yuschick.
Results of color-contrast() on button :hover and :focus pseudo-classes

Enforcing a Target Contrast Ratio

The third parameter of color-contrast() is what turns the function from a convenience into a policy tool. It accepts either a keyword — AA, AA-large, AAA, or AAA-large — or a numeric contrast ratio.

A pseudo-code example of the optional third parameter for the color-contrast function
Optionally define a target contrast using color-contrast. (Large preview)

When a target is specified, the function no longer picks the highest-contrast candidate. Instead, it returns the first color in the list that meets or exceeds the target.

See the Pen [CSS Color-Contrast() - Target Contrast Ratio Demo [forked]](https://codepen.io/smashingmag/pen/dydMryr) by Daniel Yuschick.

See the Pen CSS Color-Contrast() - Target Contrast Ratio Demo [forked] by Daniel Yuschick.
Results of dynamic target contrasts

The interesting behavior appears when no color in the list can reach the target. CSS fills the gap by supplying a color that does — black or white, whichever achieves the required contrast against the base.

h1 {
  color: color-contrast(#000 vs #111, #222 to AA);
}

See the Pen [CSS Color-Contrast() - Target Contrast Demo [forked]](https://codepen.io/smashingmag/pen/XWZdGmq) by Daniel Yuschick.

See the Pen CSS Color-Contrast() - Target Contrast Demo [forked] by Daniel Yuschick.
With low contrast text and background blocks, the color-contrast function creates accessible text colors when a target contract ratio is defined
Low contrast results with and without a target contrast defined. (Large preview)

Consider a base color of black with a color list containing only dark greys. None of the list values come close to an AA (4.5) contrast target. The function responds by falling back to white, the only color that can satisfy the requirement.

The same principle applies in a more realistic scenario:

.dark-mode {
  --bg: #000;
  --color-list: #111, #222;
}

.dark-mode {
  background: var(--bg);
  color: color-contrast(var(--bg) vs var(--color-list));

  &.with-target {
    color: color-contrast(var(--bg) vs var(--color-list) to AA);
  }
}

The .dark-mode class alone sets color to #222, the highest-contrast color from the list relative to black — but with a contrast ratio of only 1.35, it's far from accessible. Combining .dark-mode with .with-target changes the outcome: since no list value meets the AA (4.5) target, white is selected instead.

For design systems, that capability provides granular enforcement of accessibility levels. The target contrast can be defined as a :root-scoped custom property, making accessibility requirements global yet adjustable.

That control, however, has trade-offs. The code no longer communicates its result directly — nothing in the color list explains why white is being returned. Teams implementing a custom theme may be surprised to see black or white substituted for the colors they passed in. Clear documentation helps, but it shifts the burden of understanding onto the integrator, which isn't always ideal.

When Contrast Ratios Aren’t Enough

color-contrast() compares colors and returns a value based on mathematical contrast ratios — but accessibility isn’t purely mathematical. The function does not account for font size, weight, or opacity, all of which influence how legible text actually is. A pairing can meet a contrast threshold on paper and still be inaccessible in practice if the type is small, light, or semi-transparent. For guidance on accessible typography beyond color, Carie Fisher’s talk, “Accessible Typography Essentials,” is a useful resource.

Fallbacks and Progressive Enhancement

Because CSS custom properties support fallback values, it seems natural to use color-contrast() as a progressive enhancement, like this:

--article-color: color-contrast(#000 vs #333, #FFF);
color: var(--article-color, var(--fallback-color));

But this fails in an unexpected way. In browsers that don’t support the function, the custom property is still defined — it just holds the function as its value:

CSS styles in Chrome DevTools showcasing how unsupported browsers interpret the color-contrast function
Unsupported browsers, like Chrome, will use color-contrast() as a value. (Large preview)

Since --article-color is technically defined, the declared fallback never kicks in. The function’s parse error doesn’t invalidate the custom property declaration itself.

That said, progressive enhancement is still possible by wrapping usage in @supports(). But given how limited browser support is — and the chance the syntax could shift — it’s wise to be cautious about scattering this feature across a production codebase.

@supports (color: color-contrast(#000 vs #fff, #eee)) {
  --article-color: color-contrast(var(--article-color) vs #fff, #000);
}

Highest Contrast Is Not Accessible Contrast

When no target contrast is given, color-contrast() picks the color with the highest contrast ratio against the base. That doesn’t mean the result is accessible:

h1 {
  background: #000;
  color: color-contrast(#000 vs #111, #222);
}

With a black background, comparing #000 against two dark greys will select #222 because it has the greater ratio — but black on #222 is still far from useful.

Gradients Aren’t Covered

Gradients don’t work with color-contrast(), which makes sense on reflection: a gradient spans many colors, so there’s no single base color to measure against — and the function can’t understand where content sits within the visual space. Michelle Barker has experimented with pairing color-mix() and color-contrast() to address this exact limitation.

Where color-contrast() Fits

To recap: color-contrast() takes a base color and a list of candidates, then returns either the candidate with the highest contrast, the first one to hit a target ratio, or a dynamically mixed color that meets the target. Combined with progressive enhancement, this could meaningfully improve accessible theming.

There are still open questions about how the function will be adopted:

  • How could it support light, dark, and high-contrast modes?
  • Could a React-based design system expose an optional targetContrast prop on ThemeProvider to enforce accessibility?
  • Would it ever be useful to return the lowest-contrast match?
  • If there were two base colors, could it find the best value against both?

Whether you’re planning deeper integrations or just experimenting, the practical starting point is the specification and current browser support — see the W3 Color Module Level 5 docs, MDN, and caniuse for uptodate details.