Skip to main content

Harshal V. LADHE

contrast-color(): Automatic Readable Text on Any Background

Let CSS choose readable white or black text automatically.
Published at:
Last updated:
Estimated reading time:10 min read
Cover titled contrast-color(): Automatic Readable Text on Any Background, showing eight colour cards from yellow to navy, each with a large Aa sample in black or white and a black or white label, above the rule color: contrast-color(var(--bg)).(opens full-size image in a new tab)
Eight background colours, each with black or white text chosen by one contrast-color() rule.

Introduction

A button whose background comes from a design token, a tag whose colour a user picked, a badge tinted by a category: whenever a background colour isn't known in advance, someone has to decide whether the text on it should be light or dark. Until now that meant a second token for every colour (--brand and --brand-text), a Sass function run at build time, or a few lines of JavaScript that work out the colour's luminance in the browser.

contrast-color() moves that decision into CSS. Give it a colour and it returns white or black, whichever contrasts more with it, so the text stays readable when the background changes:

.badge {
  background: var(--badge-bg);
  color: contrast-color(var(--badge-bg));
}

It's defined in CSS Color Level 5 and supported in Chrome and Edge 147, Firefox 146 and Safari 26.

What It Returns

The current spec gives contrast-color() exactly one argument, a colour, and exactly two possible results:

contrast-color(white)    /* black */
contrast-color(#facc15)  /* black: yellow is light */
contrast-color(#6d28d9)  /* white: violet is dark */
contrast-color(black)    /* white */

The rules, as CSS Color 5 states them:

  • The result is always white or black, whichever gives the most contrast for text when the argument is used as a solid background.
  • If white and black contrast equally, the result is white.
  • The algorithm is up to the browser. The spec doesn't fix one at this level, and it advises browsers not to simply use the WCAG 2.1 contrast ratio to choose, because that formula has known issues.
  • Whatever the browser picks should still meet WCAG 2.1 AA for large text, a ratio of at least 3:1, because many sites have legal requirements tied to WCAG.

That third point matters more than it looks. Two browsers may disagree on a few mid-tone colours, and a colour that gets black text in one could get white in another. Both answers are allowed, as long as they reach 3:1.

The argument can be any <color>: a hex value, a named colour, oklch(), color-mix(), light-dark() or a custom property holding one of them. It can't be a gradient or an image.

With Custom Properties and Brand Colours

contrast-color() is most useful when the background comes from a variable. Define the colour once, and the text colour follows it everywhere it's used:

:root {
  --brand: #6d28d9;
}

.button {
  background: var(--brand);
  color: contrast-color(var(--brand)); /* white on this violet */
}

Rebrand to a pale yellow and the button's text turns black with no other change. Because it's an ordinary colour value, it also works for borders, icons and fill:

.button svg {
  fill: contrast-color(var(--brand));
}

Hover and Active States

Each state that changes the background needs its own contrast-color(), since a darker hover colour may need the other text colour:

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

.button:hover {
  --bg: color-mix(in oklch, var(--brand), black 20%);
}

Putting the background in a local --bg property means the hover rule only changes one value. color reads the same --bg, so it's recalculated for the hover colour automatically.

Badges and Tags with Colours from Data

Colours that come from content, such as labels in an issue tracker or categories chosen in a CMS, are where this pays off most. The colour arrives as an inline custom property, and one rule handles every tag:

<ul class="tags">
  <li class="tag" style="--tag: #fde68a">design</li>
  <li class="tag" style="--tag: #1e3a8a">backend</li>
  <li class="tag" style="--tag: #10b981">docs</li>
</ul>
.tag {
  background: var(--tag, #e5e7eb);
  color: contrast-color(var(--tag, #e5e7eb));
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
}

The pale yellow and the green get black text and the navy gets white, without anyone storing a text colour next to each tag. The fallback inside var() keeps the background and text colour in step when a tag has no colour set.

Pairing with light-dark()

light-dark() picks one of two colours depending on the active colour scheme. Pass its result to contrast-color() and the text colour follows the theme as well:

:root {
  color-scheme: light dark;
  --accent: light-dark(#1d4ed8, #93c5fd);
}

.pill {
  background: var(--accent);
  color: contrast-color(var(--accent));
}

In light mode the accent is a deep blue and the pill's text is white. In dark mode the accent becomes a light blue and the text turns black. You only choose the two accent colours; the text colour for each is worked out for you.

Try It Live

Pick any background colour, or choose one from the swatch grid. The preview uses contrast-color() for its text, and the readout shows which colour your browser returned, the WCAG 2 contrast ratio of the background against white and against black (worked out in JavaScript), and whether the browser's choice agrees with the WCAG 2 ratio. Each swatch also uses contrast-color(), and shows the ratio of the text colour it was given. Try a mid-tone such as #777777 or #3b82f6 to see how close the two options get.

contrast-color() Text on Any Background

Pick any background colour and see the white or black text contrast-color() returns on a preview, button, badge and tag, with the WCAG 2 ratio against white and against black worked out in JavaScript, a flag when the browser's choice differs from WCAG 2, and a swatch grid of light, dark and mid-tone colours.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>contrast-color() Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>Readable Text on Any Background with contrast-color()</h1>
        <p>Pick a background colour. The text on it is set with <code>color: contrast-color(var(--bg))</code>, so the browser chooses white or black. The readout compares that choice with the WCAG 2 contrast ratio against each, worked out in JavaScript.</p>
      </header>

      <p class="tip" id="support-tip" hidden>Your browser doesn't support <code>contrast-color()</code>, so the text below uses a fixed white fallback. It's supported in Chrome and Edge 147, Firefox 146 and Safari 26 or later. The WCAG ratios in the readout still work.</p>

      <fieldset class="control-group">
        <legend>Background</legend>
        <div class="fields-grid">
          <div class="field">
            <label for="bg-picker">Colour picker</label>
            <input type="color" id="bg-picker" value="#6d28d9">
          </div>
          <div class="field">
            <label for="bg-hex">Hex value</label>
            <input type="text" id="bg-hex" value="#6d28d9" spellcheck="false" autocomplete="off" pattern="#[0-9a-fA-F]{6}">
          </div>
        </div>
      </fieldset>

      <section class="demo-area preview" id="preview" aria-label="Preview on the chosen background">
        <p class="preview-title">The quick brown fox jumps over the lazy dog</p>
        <p class="preview-small">Small body text is where a close call shows first.</p>
        <div class="preview-row">
          <span class="preview-button">Button</span>
          <span class="preview-badge">Badge</span>
          <span class="preview-tag">#tag</span>
        </div>
      </section>

      <output class="readout" id="readout" aria-live="polite"></output>

      <fieldset class="control-group">
        <legend>Swatches</legend>
        <p class="hint">Each swatch sets its own text with <code>contrast-color()</code> and shows the WCAG 2 ratio of that text. Select one to load it above.</p>
        <div class="swatches" id="swatches"></div>
      </fieldset>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Read-only
Ln –, Col –HTML2.3 KBUTF-8

Starting sandbox…

No console output yet.

The Limits

Black or White Only

contrast-color() can't return your brand's near-black, a tinted white, or a darker shade of the background. If the design calls for #1f2937 text rather than pure black, you still need to choose that colour yourself.

Mid-Tones Are Close Calls

With the WCAG 2 formula, the better of white and black always reaches at least 4.58:1, so one of them always passes AA for normal text (4.5:1). The worst case is a background with a relative luminance of about 0.18, a mid grey such as #777777, where white scores 4.48:1 and black 4.69:1. Neither gets anywhere near AAA (7:1).

Two things follow from that:

  • On mid-tones the result is a narrow win either way, so check those colours rather than assuming the text is comfortable to read.
  • Since the algorithm is the browser's choice, it may pick the colour WCAG 2 rates lower. Take #3b82f6: WCAG 2 gives black 5.71:1 and white 3.68:1, but many people find white easier to read on that blue, which is the kind of known issue the spec has in mind. A browser that picks white there still meets the spec's 3:1 floor, but white text on that blue fails WCAG 2 AA for normal-size text.

If your site has to pass a WCAG 2 audit, test the colours you actually use, and avoid mid-tone backgrounds behind small text. The playground's readout flags any colour where the browser and the WCAG 2 ratio disagree.

Contrast Isn't Legibility

The spec makes the same point: enough contrast is one part of readable text, not all of it. Font size, weight, the typeface and the colours around an element matter too. A 3:1 pair is fine for a large heading and poor for 12px body text.

No Gradients or Images

The argument has to be a single colour, so contrast-color(linear-gradient(...)) is invalid. For text on a gradient, pass the stop that's hardest to read against, or the colour at the point where the text sits. For text over a photo, put a solid or semi-opaque overlay behind it and pass the overlay's colour.

No Control Yet

A draft of CSS Color Level 6 sketches a longer form of contrast-color() with your own candidate colours and a target contrast level. Its keywords are still marked "to be decided" and no browser supports it, so any declaration using extra arguments is invalid today. Stick to the single-argument form.

Browser Support and Fallbacks

contrast-color() is supported in Firefox 146, Safari 26 and Chrome and Edge 147. Chrome 147 completed the set in April 2026, which made it Baseline newly available, and it's one of the Interop 2026 focus areas. See What's New in CSS in 2026: What You Can Use Now for the rest of that year's CSS features, and Interop 2026 and CSS Snapshot 2026: What They Mean for CSS for what an Interop focus area means for using it.

For older browsers, set a safe text colour first and switch to contrast-color() inside a feature query:

.button {
  background: var(--brand);
  color: var(--brand-text, white); /* pre-chosen fallback */
}

@supports (color: contrast-color(red)) {
  .button {
    color: contrast-color(var(--brand));
  }
}

If your palette is fixed, the fallback can be a hand-picked --brand-text for each colour. If the colours come from data, the server or a few lines of JavaScript can calculate a fallback text colour with the WCAG formula and set it as --brand-text.

Common Pitfalls

1. Relying on a Repeated Declaration as the Fallback

Repeating color with a fallback first doesn't work here:

.button {
  color: white;
  color: contrast-color(var(--brand)); /* the fallback above is never used */
}

Any declaration containing var() is accepted when the stylesheet is parsed, so an older browser keeps the second line. It only discovers that contrast-color() is unknown when it works out the value, and by then white has already been discarded, so color falls back to the inherited value instead. Use @supports.

2. Forgetting the Hover State

If :hover changes the background but not the text, the text keeps the colour chosen for the original background. Recalculate it from the same variable, as in the hover example above.

3. Using the Wrong Colour as the Argument

The argument should be the colour actually behind the text. A semi-transparent background mixes with whatever is underneath it, so contrast-color(rgb(0 0 0 / 0.2)) is judging a colour that never appears on screen. Pass the colour you see.

4. Treating It as a Guarantee of AA

The spec only asks for 3:1 (AA large text). For small text on mid-tone backgrounds, check the ratio yourself.

Where Should You Use This?

  • Buttons, badges and chips whose background comes from a design token or theme variable
  • Tags, labels and avatars coloured by user choice or data, where nobody stores a matching text colour
  • Theme builders and colour pickers that preview text on any colour
  • Charts and legends where each series colour needs a readable label

Native form controls already make a similar choice for their accent colour, as Theming Native Controls with accent-color explains.

Reach for something else when the design needs a specific text colour other than black or white, when the background is an image or gradient, or for fixed colours where a designer has already picked the text colour.

Frequently Asked Questions

Key Takeaway

contrast-color(<color>) returns white or black, whichever contrasts more with the colour you pass it, so text on variable backgrounds stays readable without a second token or JavaScript. The algorithm is up to the browser and only promises 3:1, so test mid-tones that sit behind small text. It doesn't accept gradients or images, and it only chooses between black and white. Pair it with light-dark() for themed accents, and wrap it in @supports with a fixed fallback for older browsers.

Changelog

  • — Initial publication.
This tutorial is licensed under CC BY 4.0 by the author.

Share this tutorial

Spotted a mistake or something outdated in this tutorial? Suggest a correction.

  • CSS Custom Functions with @function: Reusable Logic Without Sass

    Native, reusable CSS functions with parameters — no Sass needed.
    Define native CSS custom functions with @function: typed parameters, defaults, fallbacks for older browsers, how they differ from Sass, and when to use them.
    Published at:
  • One-Line Theme-Aware Colors with light-dark()

    A single declaration per color, resolved automatically for both light and dark mode.
    The light-dark() CSS function collapses a light value and a dark value into one declaration, resolved by color-scheme — no duplicate custom properties, no @media (prefers-color-scheme) block per themed property.
    Published at:
  • HTML Attributes as Real CSS Values with Typed attr()

    Read HTML attributes as CSS colours, lengths and numbers.
    Typed attr() reads an HTML attribute as a real CSS value — type(<color>), type(<integer>), a unit like % or px, or raw-string — for any property on any element, not just text in content. Covers fallbacks for missing or invalid values, patterns for layout, transitions and view transitions, safe fallbacks in browsers without support, and when a custom property is the better choice.
    Published at: