Skip to content

Ensure text readability with dynamic background colors

When building reusable UI components (like badges, buttons, headers, or footers) or supporting customizable themes, ensuring text remains readable against a variable background color can be challenging. The contrast-color() CSS function resolves to black (rgb(0, 0, 0)) or white (rgb(255, 255, 255)), maximizing the contrast ratio (targeting at least 4.5:1 WCAG AA contrast) against the specified solid <color> value.

When an element has a variable or dynamically injected background color, you can rely on the browser to compute the safest text color.

MANDATORY: Set the color property using the contrast-color() function, passing the background color (or its custom property) as the argument.

.badge {
/* Define the background color as a custom property */
--badge-bg: #e02424;
background-color: var(--badge-bg);
/* MANDATORY: Dynamic contrasting color */
color: contrast-color(var(--badge-bg));
}

DO NOT: Use contrast-color() for background-color. It is specifically designed for foreground colors (like text or borders) to contrast against a given background color.

Because contrast-color() accepts any valid <color> value, including CSS custom properties, it is especially powerful within design systems. By centralizing your background themes as variables, you ensure that foreground content automatically updates its contrast whenever the theme shifts, even to user-specified colors.

DO: Reference shared background variables inside contrast-color() to guarantee synchronization across your application.

.theme-card {
background-color: var(--theme-surface-color);
color: contrast-color(var(--theme-surface-color));
}
.theme-container[data-theme="light"] {
--theme-surface-color: #f4f4f4;
}
.theme-container[data-theme="dark"] {
--theme-surface-color: #1a1a1a;
}
.theme-container[data-theme="custom"] {
--theme-surface-color: var(--user-custom-theme);
}

DO NOT: Hard-code a separate text color for each interaction state when the background is dynamic. Doing so reintroduces the very mismatch contrast-color() exists to prevent.

Baseline: contrast-color

To support browsers without contrast-color(), you must provide a fallback. If the background color is known and fixed, use a hard-coded contrasting color. For variable background colors (such as theme custom properties where the runtime color is unknown), choose one of the five fallback strategies from the table below based on your UI requirements and browser support targets.

Strategy Best For… Considerations
Calculate contrast with color(xyz) Automated, CSS-only contrast calculation, ensures compliance with WCAG 1.4.3 Contrast (Minimum). Highest quality CSS-only fallback.
Text Shadow Quick readability boost on any background. Can look “dirty” or “glowy”; may not fit all designs.
Text Stroke Preserving font weight while ensuring edge contrast. Use paint-order to avoid thinning letterforms, if available.
Translucent Overlay Ensuring a minimum contrast area behind the text. Changes the look of the background color under the text, requires a separate text element.
SVG Filters Reactive contrast that updates as the background changes. Requires a separate text element; hacky implementation.
Section titled “Recommended: Calculate contrast with color(xyz)”

In order to adhere to WCAG’s 1.4.3 Contrast (Minimum) guideline, calculate the contrasting color using Relative Color Syntax in the CIE XYZ D65 color space, which uses relative luminance as the y value. Note that white is color(xyz 0.9505 1 1.089), as this is the D65 white point.

Baseline: relative-color

{# Formula for this is from Lea Verou’s blog post: https://lea.verou.me/blog/2024/contrast-color/#xyz-wcag #}

--wcag-y-threshold: 0.17913;
--y: max(0, sign(var(--wcag-y-threshold) - y));
color: color(from var(--color) xyz-d65 calc(var(--y) * 0.9505) var(--y) calc(var(--y) * 1.089));

DO: Select the fallback that best matches your design constraints if RCS is not sufficient or supported.

Baseline: text-stroke-fill

.badge--stroke {
-webkit-text-stroke: 4px black;
}

Option 2: Text Stroke with paint-order to preserve letterforms

Section titled “Option 2: Text Stroke with paint-order to preserve letterforms”

Baseline: paint-order

.badge--stroke {
-webkit-text-stroke: 4px black;
paint-order: stroke fill;
}
.badge--overlay {
position: relative;
color: #fff;
overflow: hidden;
}
.badge--overlay::before {
content: "";
position: absolute;
inset: 0;
background-color: rgb(0 0 0 / 0.4);
z-index: 0;
}
.badge--overlay span {
position: relative;
z-index: 1;
}
.badge--shadow {
text-shadow: 0 1px 3px rgb(0 0 0 / 0.8);
}

Baseline: svg-filters

  1. Load this SVG filter definition. It must be added directly to the HTML document if you need to support Safari versions affected by this WebKit bug, which prevents external and data url references for filters. Otherwise, it should be loaded from an external file, either an SVG file or inlined in CSS.
<!-- Technique from https://miunau.com/posts/dynamic-text-contrast-in-css/ -->
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="0" style="display: none;">
<defs>
<filter id="contrast-filter" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix"
values="0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0.2126 0.7152 0.0722 0 0
0 0 0 1 0"/>
<feMorphology operator="dilate" radius="2"/>
<feComponentTransfer>
<feFuncR type="linear" slope="-255" intercept="128"/>
<feFuncG type="linear" slope="-255" intercept="128"/>
<feFuncB type="linear" slope="-255" intercept="128"/>
</feComponentTransfer>
<feComposite operator="in" in2="SourceGraphic"/>
</filter>
</defs>
</svg>
  1. Wrap the text in a <span>.
<div class="badge--svg">
<span>Badge content</span>
</div>
  1. Apply the filter to the <span>.
.badge--svg span{
color: var(--badge-bg);
filter: url(#contrast-filter);
}

Compatibility notes: Badges like Baseline: … mark web features referenced by this guide. Baseline status describes availability across the core browser set — it does not mean every browser or older version supports the feature. Feature data as published upstream, synced 2026-11-12.

Open the demo standalone · Demo source on GitHub


Source: GoogleChrome/modern-web-guidance-src (Apache-2.0), upstream commit 179dbc6, synced 2026-11-12. Local changes: macros expanded to links/badges, first heading de-duplicated, demo and this footer added. This is an independent, unofficial site — not a Google publication.