Web Development

The Persistent Challenge of Web Accessibility: 70% of Websites Fail Basic Contrast Checks Despite Years of Tooling

Seventy percent of websites still fail basic WCAG contrast checks in 2025. After years of design system tooling, accessibility linters, and JavaScript libraries, nothing moved the needle. We didn’t need better libraries. We needed better CSS. contrast-color() is that better CSS.

The persistent struggle to achieve adequate color contrast on the web continues to plague digital accessibility, with a staggering 70% of websites failing basic WCAG contrast checks in 2025, according to data from the HTTP Archive Web Almanac. This statistic, largely unchanged over several years, highlights a critical disconnect between the intention to build accessible websites and the practical implementation. Despite the proliferation of design system tooling, specialized accessibility linters, and JavaScript libraries dedicated to ensuring readable text colors, the needle has barely moved. The issue, it appears, is not a lack of tools but a fundamental need for more integrated and efficient solutions within the core web technologies themselves.

Further underscoring the severity of the problem, the WebAIM Million project paints an even grimmer picture. In 2026, 83.9% of homepages were flagged for low contrast text, an increase from 79.1% in the previous year. While some benchmarks might show marginal improvements of a few percentage points annually, others indicate a worsening trend. This lack of substantial progress suggests that relying on runtime JavaScript for such a fundamental aspect of web design is simply not scalable across the vast and diverse landscape of the open web. The consensus is emerging: the solution lies not in more external libraries, but in enhanced capabilities within CSS itself.

Enter the contrast-color() function, a novel CSS feature designed to address this long-standing accessibility hurdle directly. This CSS function promises to bring the power of contrast calculation directly into the browser’s styling engine. With a single declaration, the browser can perform the necessary contrast calculations during the style computation phase, prior to the page being rendered. This means developers can specify a background color, and the browser will automatically determine and apply the optimal contrasting text color – black or white – without the need for external JavaScript libraries, complex build steps, or the dreaded "hydration flash" often seen in single-page applications.

It is important to note a recent nomenclature change: older articles and specification drafts may refer to this function as color-contrast(). However, this name has been updated to contrast-color(), and the older syntax is no longer supported in modern browsers.

What It Does (And What It Doesn’t)

The current implementation of contrast-color(), as defined in CSS Color Module Level 5, is remarkably straightforward. You provide it with a color, and it returns either the string black or white, whichever offers the highest contrast against the input color.

Consider a common button styling scenario:

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

In this example, if --brand-color is set to a vibrant neon green, the browser will automatically apply black text for optimal readability. Conversely, if --brand-color is changed to a deep midnight navy, white text will be rendered. This dynamic adaptability is particularly powerful when coupled with CSS custom properties. When themes are swapped at runtime via JavaScript, the text color will adjust instantly without requiring event listeners or explicit recalculations, streamlining the development process and enhancing user experience.

However, it’s crucial to understand the current limitations of the Level 5 specification:

  • Output is Binary: The function currently only outputs black or white. While effective for basic contrast, it doesn’t offer nuanced color choices.
  • WCAG 2.x Basis (Currently): The underlying algorithm used by browsers for this calculation is typically based on WCAG 2.x relative luminance. While this ensures compliance with existing standards, it doesn’t inherently account for newer perceptual models.
  • No Perceptual Guarantees: As detailed later, mathematical compliance does not always equate to perceptual accessibility.

The Spec Split: Level 5 Versus Level 6

The contrast-color() function’s development is an interesting case study, as it spans two distinct specification levels: CSS Color Module Level 5 and Level 6. This dual-specification approach has implications for its current implementation and future potential.

CSS Color Module Level 5: This is the level that defines the functionality currently shipping in browsers. Its simplicity is its strength: one color in, black or white out. The algorithm itself is deliberately marked as "UA-defined" (User Agent-defined). This means that while current browser engines predominantly use the WCAG 2.x relative luminance formula, there’s a built-in flexibility to adopt different algorithms in the future without breaking existing websites. This "UA-defined" label is a strategic escape hatch, allowing for evolution.

The context for this flexibility is the ongoing development and debate around the Accessible Perceptual Contrast Algorithm (APCA). APCA aims to model how human eyes perceive contrast more accurately, taking into account factors like font weight and ambient light. By not locking the Level 5 specification into a rigid adherence to WCAG 2.x, browser vendors can seamlessly transition to APCA or other improved algorithms when they become standardized.

However, the future of APCA itself is less certain than its proponents might suggest. A critical development occurred in mid-2023 when APCA was removed from the WCAG 3 working draft. This decision followed a lack of sufficient support within the Working Group. The current WCAG 3 specification states that the contrast algorithm is "yet to be determined," with the standard itself potentially not finalized until 2030 or later. Furthermore, in May 2024, a Chromium issue was filed requesting the removal of the experimental "Advanced Perceptual Contrast Algorithm" flag from developer tools. The rationale provided was that the current implementation is outdated and could mislead developers into believing APCA is more official or further along in its development than it is.

While APCA’s integration into WCAG 3 remains uncertain, the underlying research is peer-reviewed and substantive. It’s widely acknowledged that colors passing APCA guidelines generally exceed WCAG 2 minimums. The current ambiguity means that while contrast-color() can adapt, the specific algorithm it employs might evolve, and the Level 6 features are being developed with an algorithm whose final form is still in flux.

CSS Color Module Level 6: This level introduces more advanced syntax, including candidate color lists and target contrast ratios. An example of this future syntax illustrates its potential:

/* Level 6 future syntax — not shipping yet */
color: contrast-color(var(--bg) tbd-bg wcag2(aa), #1a1a2e, #e2e8f0, #fbbf24);

This syntax would allow developers to specify a list of candidate colors, and the browser would select the first one that meets a specified contrast threshold, such as the WCAG 2 AA ratio of 4.5:1. The keywords tbd-fg and tbd-bg would indicate whether the base color is for foreground or background, crucial for directional contrast models like APCA. However, given the ongoing uncertainty surrounding APCA and the broader WCAG 3 timeline, these Level 6 features are still firmly in the working draft stage. For now, the Level 5 implementation remains the practical choice.

Browser Support

The browser support for contrast-color() is remarkably robust, especially for a relatively new CSS feature. Major browser engines have integrated it into their stable releases:

  • Chrome: Version 147 (released April 2026)
  • Firefox: Version 146
  • Safari: Version 26.0

This feature achieved "Baseline Newly Available" status in April 2026, indicating widespread and stable adoption. Comprehensive details can be found on caniuse.com. Crucially, all three major browser engines have passed the Web Platform Tests for contrast-color(), ensuring consistent behavior across browsers regarding edge cases like tie-breaking logic, color space conversions, and syntax parsing.

While global support percentages on aggregate sites might appear lower, this often reflects outdated enterprise browsers or users who delay updates. For the majority of active web users, this feature is already available.

Implementing progressive enhancement with contrast-color() is straightforward using the @supports rule:

.card 
  background: var(--bg);
  color: #fff;
  text-shadow: 0 0 4px rgb(0 0 0 / 0.8); /* Fallback for older browsers */


@supports (color: contrast-color(red)) 
  .card 
    color: contrast-color(var(--bg));
    text-shadow: none; /* Remove shadow when native support is present */
  

This approach ensures that older browsers receive a legible white text with a subtle dark shadow for contrast. Modern browsers, however, will leverage the native contrast-color() function, enjoying the dynamic and efficient calculation. No user is left with illegible text.

A point of caution for teams employing automated accessibility scanners (like Lighthouse or Axe) is that they may not fully evaluate text shadows. These scanners typically focus on the computed color property against the background-color. Consequently, the fallback styling with a text shadow might still trigger a contrast failure in CI/CD pipelines, even though it provides adequate legibility to human eyes. Teams may need to implement allowlisting for this specific rule or add comments to explain these false positives.

A Note on PostCSS: A PostCSS plugin, @csstools/postcss-contrast-color-function, exists to evaluate contrast-color() at build time. This is effective for static color values, such as contrast-color(#ff0000). However, when custom properties are involved, like contrast-color(var(--bg)), the plugin cannot resolve the dynamic runtime values. For theming scenarios where colors change dynamically, relying on the native @supports block and browser-native functionality is the recommended approach, rather than using a build-time polyfill.

The Gotchas

Despite its power, contrast-color() comes with a few important caveats that developers must be aware of:

Algorithmic Theming Engines: Building Self-Correcting Color Systems With contrast-color() — Smashing Magazine

It Doesn’t Guarantee Perceptual or AAA Compliance

A common misconception is that using contrast-color() automatically ensures all accessibility requirements are met. While it typically provides mathematical compliance with WCAG AA standards, it’s essential to understand its limitations:

  • Mathematical vs. Perceptual Contrast: The WCAG 2.x relative luminance formula, while a standard, has known perceptual blind spots. For instance, a medium blue like #2277d3 might mathematically pass the 4.5:1 AA ratio with black text, but it can be difficult for the human eye to read. contrast-color() delivers mathematical compliance, which is excellent for automated audits, but it doesn’t always translate to perfect perceptual accessibility. This is precisely why APCA, which aims for better perceptual accuracy, is being developed.
  • WCAG AAA Nuances: For the stricter WCAG AAA standard (7.0:1 contrast ratio), a true "dead zone" exists. For backgrounds with luminance between approximately 10% and 30%, neither pure black nor pure white will achieve the required 7:1 ratio. In such cases, contrast-color() will simply return the "least bad" failing option, and developers will need to employ alternative strategies or adjust their color palettes.

Transitions Snap, Not Fade

When animating background colors, the contrast-color() function’s output can lead to abrupt transitions in text color. If a background color animates smoothly from white to black, the text color, being a discrete output of black or white, cannot be interpolated. It will snap rather than fade.

.btn 
  background-color: #fff;
  color: contrast-color(#fff); /* black */
  transition: background-color 1s, color 1s;

.btn:hover 
  background-color: #000;
  color: contrast-color(#000); /* white */

This snap doesn’t occur at the visual midpoint of the animation. The WCAG 2.x relative luminance scale is non-linear, with the mathematical tipping point (where black and white offer equal contrast) occurring around 18% relative luminance. This means the text will remain the initial color for most of the animation and then snap to the new color very late in the transition, creating a jarring visual effect. While transition-behavior: allow-discrete can shift the timing of this snap to the 50% mark of the animation duration, it doesn’t resolve the fundamental issue of interpolating a binary output. For smooth text color transitions, developers might need to explore techniques like color-mix() or implement custom crossfade logic.

Tie Goes To White

In the rare instance where a background color results in an identical contrast ratio for both black and white text, the CSS Color Module Level 5 specification dictates that white is the default winner. This is a minor detail but worth noting if debugging unexpected color choices for neutral grays.

Gradients and Images Are Out

The contrast-color() function is designed to work with a single, flat <color> value. It cannot process gradients or image URLs directly. Passing a linear-gradient() or url() to the function will result in a parse error. For elements with complex backgrounds like photographs or intricate gradients, developers will still need to rely on JavaScript or manual color picking for overlay text.

Transparent Colors Are Composited First

When a semi-transparent color is provided to contrast-color(), the browser composites it against an assumed opaque background (typically white) before performing the contrast calculation. This means the function doesn’t "see through" the element to the actual background. Developers should be aware that the alpha channel is handled through compositing, which may yield unexpected results if a transparent background was anticipated.

Windows High Contrast Mode

In scenarios where a user enables Windows High Contrast mode, the browser activates the forced-colors: active media query. This overrides author-defined colors with system-defined colors to ensure legibility. contrast-color() respects this setting and defers to system colors like CanvasText, eliminating the need for manual media queries to override contrast logic.

Combining It With Other Color Functions

While the output of contrast-color() is limited to black or white, its true power is unleashed when combined with other modern CSS color functions. This allows for the creation of sophisticated and adaptable component palettes from a single custom property.

Brand-Tinted Contrast With Relative Color Syntax

Pure black or white text can sometimes feel flat against vibrant backgrounds. A more nuanced approach involves tinting the contrast text with a subtle hue derived from the background color. This can be achieved using the oklch() color space and the relative color syntax:

.card 
  --bg-hue: 260; /* Indigo */
  --bg: oklch(0.6 0.1 var(--bg-hue));
  background: var(--bg);

  /* Pull L from the black/white contrast color,
     but inject subtle chroma and the background's hue */
  color: oklch(from contrast-color(var(--bg)) l 0.05 var(--bg-hue));

In this example, when contrast-color() returns white, the l value is 1 (full lightness). When it returns black, l is 0. By reintroducing the background’s hue and adding a touch of chroma, the text appears as a deep indigo or a pale icy indigo, rather than generic black or white. This technique, while visually appealing, requires careful testing, as altering the lightness and chroma can potentially push borderline contrast ratios into failing territory. Developers must always validate such tinted outputs with an accessibility linter.

It’s also important to note that this example combines two cutting-edge features: contrast-color() and oklch(from ...). For robust implementation, a @supports block should test for the availability of both:

@supports (color: contrast-color(red)) and (color: oklch(from red l c h)) 
  /* Safe to use both */

Softened Contrast With color-mix()

A simpler method for softening contrast involves mixing the sharp black or white output back into the background color using color-mix():

.alert 
  --bg: var(--alert-color);
  background: var(--bg);

  /* 80% contrast, 20% background = softer but readable */
  color: color-mix(in oklch, contrast-color(var(--bg)) 80%, var(--bg));

  /* 40% contrast for a subtle border */
  border: 1px solid
    color-mix(in oklch, contrast-color(var(--bg)) 40%, var(--bg));

This pattern is also highly effective for styling ::placeholder text, a common challenge in dynamic theming. By mixing the contrast color with the background at a lower percentage (e.g., 50%), developers can achieve muted yet legible placeholder text that automatically adapts to background changes.

Theme-Aware Contrast With light-dark()

For applications supporting system-level light and dark modes, the light-dark() function can be used in conjunction with contrast-color() for seamless theme transitions:

:root 
  color-scheme: light dark;
  --surface: light-dark(#fff, #121212);


.component 
  background: var(--surface);
  color: contrast-color(var(--surface));

When the operating system switches to dark mode, --surface automatically resolves to #121212, and contrast-color() subsequently returns white. This native resolution eliminates the need for JavaScript theme detection or explicit media queries.

What You Can Remove From Your Bundle

The introduction of native contrast-color() functionality presents a significant opportunity to reduce reliance on JavaScript libraries, thereby shrinking bundle sizes and improving runtime performance. Many libraries historically used for readable text color selection can now be deprecated for this specific use case:

Library Size (approx.) What it did
chroma-js ~14 kB Color parsing, luminance calc, readable color
polished ~11 kB readableColor() for styled-components
tinycolor2 ~5 kB Hex parsing, WCAG contrast ratio math

While these libraries may still be valuable for generating complex color scales or performing other color manipulations, their role in simply determining readable text color is now redundant.

Beyond bundle size, the performance implications are substantial. JavaScript libraries tasked with contrast calculations execute on the main thread, competing with other critical tasks like layout rendering and event handling. Every theme change or component mount involving dynamic backgrounds necessitates JavaScript processing for color parsing, luminance computation, and DOM updates. contrast-color(), conversely, offloads these computations to the browser’s highly optimized C++ rendering engine during the style computation phase, prior to the page even painting. This shift can lead to a tangible improvement in application responsiveness, particularly in highly themed environments.

Furthermore, moving contrast calculations to CSS eliminates the "hydration flash" phenomenon common in server-side rendered applications (e.g., React, Vue). In such scenarios, the server sends HTML, and the client-side JavaScript then hydrates the application, calculating and applying contrast colors. This brief window between initial render and hydration can result in text appearing in the wrong color or being momentarily illegible. Native CSS support for contrast-color() ensures the correct color is resolved during the initial paint, before JavaScript even loads.

What We Used To Do

To fully appreciate the impact of contrast-color(), it’s helpful to look back at the methods previously employed:

  • Sass Era: Before native CSS custom properties and dynamic styling, developers relied on Sass or Less functions. These functions would check the lightness of a background color (e.g., lightness($bg) > 50%) at compile time and return black or white. This was effective for static themes but utterly incapable of adapting to user-selected colors, CMS palettes, or dark mode toggles, as the output was hardcoded into the CSS file.

  • The Variable Toggle Hack: With the advent of CSS custom properties, developers became more inventive. Techniques emerged that involved splitting colors into their RGB channels, calculating luminance within calc() functions, and using mathematical operations to clamp the result to 0 or 1. While functional, these methods were often arcane, difficult to maintain, and prone to silent failures if syntax errors occurred. Kevin Hamer’s elegant OKLCH-based approximation represents the most refined version of this lineage, offering better perceptual alignment and cleaner math, but it remains a workaround for a problem now addressed natively.

contrast-color() effectively consolidates all these disparate approaches into a single, declarative CSS function. Its forward-thinking design, allowing browsers to upgrade the underlying algorithm without breaking existing code, ensures that websites will continue to benefit from advancements in contrast assessment, whether that be APCA or a future successor to WCAG 2.x.

The persistent 70% failure rate in accessibility contrast checks was never a reflection of developers’ unwillingness to prioritize accessibility. Instead, it was a testament to the gap between intent and implementation – the complexities of integrating libraries, build steps, runtime calculations, and the ubiquitous hydration flash. Each point in that chain represented an opportunity for accessibility to falter. contrast-color() doesn’t magically imbue developers with more care; it makes caring effortless and cost-free.

Related Articles

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button
Jar Digital
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.