Automatic text color selection by WCAG contrast
The color-contrast() function from CSS Color Level 5 lets the browser itself decide which of several candidate text colors gives the best WCAG contrast against a given background. This removes manual contrast calculation and makes dynamically generated backgrounds automatically readable and accessible.
Table of Contents
- 1. Why contrast is a recurring problem in CSS
- 2. The color-contrast function syntax in detail
- 3. Automatic text color selection for dynamic backgrounds
- 4. Comparison to manual contrast calculation
- 5. Combining with custom properties for dynamic themes
- 6. Fallback strategies for missing browser support
- 7. Practical example: a button component with guaranteed contrast
- 8. Testing and automation with axe-core and Lighthouse
- 9. Contrast selection in direct comparison
- 10. Summary
- 11. FAQ
1. Why contrast is a recurring problem in CSS
Text contrast is among the most frequently violated WCAG criteria on the web, even though the rule itself sounds simple: normal text needs a contrast ratio of at least 4.5 to 1 against its background. The problem appears wherever background colors are not static but generated dynamically, for example from user data, from a color system with many variants, or from user defined theme colors. In these cases, a fixed text color can quickly fall below the required contrast the moment the background changes.
The color-contrast() function from the CSS Color Module Level 5 was designed exactly for this problem. Instead of hard coding a fixed text color, the browser receives a list of candidate text colors, and the function automatically picks the one that achieves the highest contrast against the given background. This shifts the contrast decision from static design time logic to a dynamic runtime calculation inside the browser itself.
2. The color-contrast function syntax in detail
The basic syntax is color-contrast(<background> vs <color1>, <color2>, ...). The browser computes the contrast ratio of every listed color against the given background and automatically picks the one with the highest value. In its simplest form, two candidates are enough, for example black and white, so the function automatically chooses the appropriate text color depending on background brightness, with no manual brightness calculation in the code.
An extended variant of the color-contrast() function additionally allows specifying a minimum contrast ratio, for example to AA or to AAA, to explicitly check against the corresponding WCAG conformance levels. This syntax is still evolving within the specification, which is why implementations can vary between browser versions, and a production use always needs to be secured with a fallback.
/* Basic color-contrast: pick the higher-contrast candidate automatically */
.badge {
background-color: var(--badge-bg);
/* Browser picks whichever of black/white contrasts more with the background */
color: color-contrast(var(--badge-bg) vs black, white);
}
/* Extended form: pick the first candidate that satisfies AA */
.alert {
background-color: var(--alert-bg);
color: color-contrast(var(--alert-bg) vs #1e293b, #f8fafc to AA);
}
3. Automatic text color selection for dynamic backgrounds
The biggest practical benefit of color-contrast() shows up in components whose background color is generated at runtime from data, for example status badges, category labels or user defined avatar colors. Without the function, a matching text color would have to be precomputed for every possible background color and stored as an additional data field, which couples backend and frontend even further. With color-contrast(), that step disappears entirely, because the contrast decision is made exclusively in CSS.
A typical example is a tag system where every tag gets a background color chosen randomly from a palette or defined by the user. Without color-contrast(), developers would either have to check every possible background color for sufficient contrast, or maintain a contrast calculation script in JavaScript. The CSS function moves this logic to the exact place where the color is actually rendered, eliminating error sources caused by outdated or incomplete server side calculations.
/* Dynamic tag backgrounds with automatic, always-readable text color */
.tag {
background-color: var(--tag-color, #7c3aed);
color: color-contrast(var(--tag-color, #7c3aed) vs #111827, #f9fafb);
border-radius: 9999px;
padding: 0.25rem 0.75rem;
}
/* Works correctly no matter which tag color is set inline */
.tag[data-color="lime"] { --tag-color: #a3e635; }
.tag[data-color="rose"] { --tag-color: #e11d48; }
.tag[data-color="amber"] { --tag-color: #f59e0b; }
4. Comparison to manual contrast calculation
Before color-contrast(), the only option for dynamic contrast selection was manually calculating relative luminance using the WCAG formula, usually implemented in JavaScript. This computes the perceived brightness from the RGB values of the background and picks a light or dark text color based on a threshold. This approach works, but requires extra JavaScript code that has to run again every time the background color changes, which can lead to a brief flash of the wrong text color, especially on server rendered pages.
color-contrast() solves this problem because the calculation is part of the CSS rendering process and works without JavaScript, without an extra network request and without any layout shift. Especially for server side rendered applications with a strict Content Security Policy, as is common in Hyvä themes for Magento, a pure CSS solution with no inline script is a significant advantage over JavaScript based contrast calculations.
5. Combining with custom properties for dynamic themes
In design systems with multiple themes, color-contrast() combines excellently with custom properties to determine the correct text color per theme automatically, without maintaining a separate text color variable for every theme. Instead of defining separate text on background combinations for a light and a dark theme, a single color-contrast() declaration is enough to produce the correct result for both themes automatically.
This significantly reduces the number of custom properties in a theming system, because text colors no longer need to be defined explicitly per theme. This effect adds up especially in component libraries with dozens of color variants: instead of maintaining two text color variables per background color, a single shared color-contrast() formula covers all variants and both color schemes at once.
/* One contrast formula, works for every theme automatically */
:root {
color-scheme: light dark;
}
.card {
background-color: var(--surface-color);
/* No separate light/dark text color variables needed */
color: color-contrast(var(--surface-color) vs #0f172a, #f8fafc);
}
@media (prefers-color-scheme: dark) {
:root {
--surface-color: #1e1b2e;
}
}
6. Fallback strategies for missing browser support
Since color-contrast() is not yet fully implemented across all major browser engines as of 2026, every production use needs a fallback strategy. The most pragmatic approach: declare a static text color first that every browser understands, and write the color-contrast() variant as a second, overriding declaration afterwards. Browsers without support automatically ignore the second, invalid declaration and keep the static text color.
Alternatively, @supports (color: color-contrast(white vs black, white)) can be used to specifically test whether the browser understands the function, and maintain a fully separate rule set for supporting and non supporting browsers. For projects with high accessibility requirements, an additional server side precomputed text color as a hard baseline is recommended, so that a WCAG compliant contrast is guaranteed even without any CSS function at all.
/* Fallback-safe declaration order for color-contrast() */
.badge {
background-color: var(--badge-bg);
color: #111827; /* static fallback, understood by every browser */
color: color-contrast(var(--badge-bg) vs #111827, #f9fafb); /* progressive enhancement */
}
/* Alternative: explicit feature detection */
@supports (color: color-contrast(white vs black, white)) {
.badge { color: color-contrast(var(--badge-bg) vs #111827, #f9fafb); }
}
7. Practical example: a button component with guaranteed contrast
A common use case is a button component that receives an arbitrary background color as a prop from a content management system or a theme editor. Editors without CSS knowledge can easily pick a background color that becomes unreadable with a hard coded white or black text color in such systems. color-contrast() prevents exactly this scenario, because the text color is always computed relative to the actually chosen background color.
In practice, this means for a CMS driven project: editors can enter any brand color in the backend as a button background, without developers needing to test every possible combination in advance. The color-contrast() function takes over the guarantee that the text stays readable, regardless of which color eventually gets entered in the backend.
8. Testing and automation with axe-core and Lighthouse
Automated accessibility tests with axe-core or Lighthouse check contrast ratios based on the actually computed color values in the DOM, not based on the CSS source code. This means text colors computed with color-contrast() get correctly captured by such tests, provided the test runs in a browser that supports the function. For CI pipelines, it is therefore recommended to use the same browser for tests that also supports the function in production, to avoid false negative test results.
It also pays off to run a manual test with the CSS feature disabled, to check the fallback text color separately for contrast. This way, a team makes sure that both the color-contrast() path and the static fallback for unsupported browsers meet WCAG requirements, instead of blindly relying on the new function.
9. Contrast selection in direct comparison
The following table compares the three common approaches to contrast selection for dynamic backgrounds.
| Criterion | Manual JS calculation | color-contrast() | Static text color |
|---|---|---|---|
| JavaScript required | Yes | No | No |
| Adapts to background | Yes | Yes | No |
| Layout shift risk | Possible | None | None |
| CSP friendly | Depends on the script | Yes, pure CSS | Yes |
| Browser support 2026 | Universal | Partial, growing | Universal |
For projects that already depend on broad browser support today, a combination of a static fallback color and progressive color-contrast() enhancement remains the safest path. As browser support grows, the manual JavaScript share can be reduced step by step, without changing the underlying architecture of the components.
Mironsoft
Accessible CSS, WCAG audits and automated contrast systems
Contrast that stays readable, guaranteed?
We build color systems with color-contrast() and clean fallback strategies that reliably deliver WCAG compliant contrast even with dynamically generated background colors, with no extra JavaScript.
Contrast audit
Checking existing components for WCAG contrast and finding weak spots
Fallback architecture
Progressive enhancement with color-contrast() and static reserve colors
CI testing
Integrating axe-core and Lighthouse into the pipeline, preventing regressions
10. Summary
The color-contrast() function moves contrast decisions to the exact place where they are actually needed, namely the browser at render time. Instead of statically precomputing text colors for every possible background color, the browser itself automatically picks the candidate color with the highest WCAG contrast. This reduces JavaScript dependencies, prevents layout shifts caused by subsequent contrast calculation, and works even under a strict Content Security Policy.
Since browser support in 2026 is not yet universal, a careful fallback through static text colors or @supports remains mandatory. Anyone using color-contrast() today as a progressive enhancement already reduces manual testing effort, and builds a color system that automatically becomes more robust as browser support grows.
The color-contrast() Function for Accessibility — The Essentials at a Glance
Basic syntax
color-contrast(bg vs a, b) automatically picks the candidate color with the highest contrast against the background.
No JavaScript needed
The calculation happens entirely in CSS rendering, with no layout shift and no CSP conflicts.
Fallback required
Declare a static text color first, then color-contrast() as an overriding second declaration.
Testing
axe-core and Lighthouse together with a manual fallback test with the CSS function disabled.