Web Development

The Persistent Struggle for Web Accessibility: Seventy Percent of Websites Still Fail Basic WCAG Contrast Checks in 2025

The digital landscape in 2025 continues to grapple with a fundamental accessibility challenge: a staggering seventy percent of websites still fail to meet basic Web Content Accessibility Guidelines (WCAG) for color contrast. Despite years of advancements in design system tooling, the proliferation of accessibility linters, and the development of sophisticated JavaScript libraries, these critical metrics have shown little to no improvement. The core issue, experts now contend, lies not in a lack of tools, but in the foundational CSS capabilities available to developers. The introduction of the contrast-color() CSS function represents a significant potential shift, offering a native CSS solution to a long-standing problem.

Data from the HTTP Archive Web Almanac, a comprehensive annual report on the state of the web, has consistently highlighted the dismal figures for color contrast failures. For half a decade, these numbers have remained stubbornly high. The 2025 edition of the Almanac reveals that a significant majority of websites still do not adhere to basic WCAG contrast requirements. Compounding this issue, the WebAIM Million project, which analyzes the homepages of the top one million most popular websites, paints an even more concerning picture. In 2026, a stark 83.9% of homepages were flagged for low contrast text, an increase from 79.1% in the previous year. This trend, showing minimal progress on one benchmark and actual regression on another, suggests that relying on runtime JavaScript for such a fundamental aspect of web design is not a scalable solution for the open web. The consensus is shifting: the solution isn’t more libraries, but fundamentally better CSS.

The contrast-color() function emerges as this much-needed improvement. This native CSS function allows browsers to perform contrast calculations directly during the style computation phase, before the page even renders. This eliminates the need for external JavaScript libraries, complex build steps, or the dreaded "hydration flash" where content might appear with incorrect styling before JavaScript loads.

A New Approach to Contrast: What contrast-color() Does (and Doesn’t Do)

The current iteration of the contrast-color() function, defined in CSS Color Module Level 5, is elegantly simple. Developers provide a background color, and the function returns either black or white, whichever offers the greatest contrast against the specified background.

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

This straightforward implementation means that if --brand-color is set to a vibrant neon green, the text will automatically render as black. Conversely, if --brand-color is a deep midnight navy, the text will appear as white. This dynamic adaptation is crucial for modern web development, especially for applications that employ theming or allow users to customize their interface. Changing themes at runtime via JavaScript will now result in instant text color adaptation without requiring event listeners or explicit recalculations, streamlining development and enhancing user experience.

It is important to note that older articles and draft specifications might refer to this function as color-contrast(). This name has been officially changed to contrast-color(), and the older syntax is no longer supported by modern browsers.

The Evolving Specification: Level 5 Versus Level 6

The contrast-color() function’s development is uniquely distributed across two specification levels, a detail that warrants understanding.

CSS Color Level 5: The Current Standard
The specification that browsers are shipping today is CSS Color Module Level 5. This level defines the core functionality: a single color input yielding either black or white as output. The algorithm used for this calculation is deliberately marked as "UA-defined" (User Agent defined). This means that while current browser engines predominantly utilize the WCAG 2.x relative luminance formula, the specification includes an intentional escape hatch. This "UA-defined" label is not an oversight; it’s a strategic design choice to allow browser vendors to adopt more advanced contrast algorithms in the future without breaking existing websites.

A significant development often discussed in this context is the Accessible Perceptual Contrast Algorithm (APCA). APCA aims to model how the human eye perceives contrast, taking into account factors such as font weight, spatial frequency, and ambient light. This represents a substantial improvement over the older WCAG 2.x formula. By not rigidly embedding "WCAG 2.x" as the default in Level 5, the specification ensures that future adoption of algorithms like APCA won’t render existing implementations obsolete. If the Level 5 spec had mandated a wcag2() keyword, all websites relying on it would have been permanently tied to the older, less perceptually accurate calculation.

However, the future of APCA itself is subject to considerable uncertainty. Adrian Roselli’s detailed analysis, "WCAG3 Contrast as of April 2026," highlights that APCA was withdrawn from the WCAG 3 working draft in mid-2023 due to insufficient support within the Working Group. The current WCAG 3 specification states that the contrast algorithm is "yet to be determined," with a potential finalization date of 2030 or later. Furthermore, a Chromium issue filed in May 2024 requested the removal of the "Advanced Perceptual Contrast Algorithm" experimental flag from developer tools, arguing that its implementation is outdated and could mislead developers about APCA’s official status.

While these developments do not signal the end of APCA research—which is based on peer-reviewed and substantive findings, with proponents noting that colors passing APCA guidelines generally far exceed WCAG 2 minimums—they underscore the uncertainty surrounding its future adoption as a formal standard. This uncertainty directly impacts contrast-color(). If a different algorithm is chosen, or if WCAG 3 introduces an entirely novel approach, the "UA-defined" nature of Level 5 ensures browsers can adapt seamlessly.

CSS Color Level 6: Future Enhancements
CSS Color Module Level 6 introduces more advanced syntax, including candidate color lists and target contrast ratios. For example:

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

This future syntax would enable browsers to evaluate a list of candidate colors from left to right, selecting the first one that meets a specified contrast threshold, such as the 4.5:1 AA ratio. Keywords like tbd-fg and tbd-bg would indicate whether the base color is foreground or background, crucial for directional contrast models like APCA. However, this Level 6 functionality remains in the Working Draft stage, particularly given APCA’s uncertain trajectory. For current development, the Level 5 implementation is the practical choice.

Browser Support and Progressive Enhancement
The browser support for contrast-color() is remarkably strong, exceeding that of many newer CSS features. All three major browser engines have integrated it into their stable releases: Chrome 147 (April 2026), Firefox 146, and Safari 26.0. By April 2026, it achieved "Baseline Newly Available" status, signifying widespread adoption. Developers can consult caniuse.com for detailed version support matrices. Crucially, all major engines have passed the Web Platform Tests for contrast-color(), ensuring consistent behavior across browsers, including edge cases like tie-breaking logic and color space conversions. While the raw global support percentage might appear modest on aggregate sites, this often reflects older enterprise browsers or users who delay updates. For the vast majority of active web users, contrast-color() is already supported.

Progressive enhancement is easily implemented using the @supports rule, ensuring a graceful fallback for older browsers:

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


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

This approach provides older browsers with white text and a subtle dark shadow for legibility, while supporting browsers leverage the native contrast-color() function. This ensures that no user experiences broken or illegible text.

A notable consideration for teams employing automated accessibility scanners (like Lighthouse or Axe) is that these tools typically do not evaluate text-shadow. They focus solely on the computed color against the background-color. Consequently, the fallback text might still be flagged as a contrast failure in CI/CD pipelines, even if the shadow effectively renders it legible to human eyes. Teams may need to whitelist this specific rule or add comments to their code to explain these false positives.

PostCSS Considerations:
A PostCSS plugin, @csstools/postcss-contrast-color-function, can evaluate contrast-color() at build time for static color values (e.g., contrast-color(#ff0000)). However, its utility diminishes significantly when custom properties are involved (e.g., contrast-color(var(--bg))), as the plugin lacks access to runtime values. For dynamic theming, relying on the @supports block and native browser functionality is the recommended approach.

Navigating the Nuances: The Gotchas of contrast-color()

While contrast-color() offers a powerful solution, developers should be aware of its limitations and potential pitfalls.

1. It Doesn’t Guarantee Perceptual or AAA Compliance:
A common misconception is that using contrast-color() automatically guarantees full accessibility compliance. While it typically ensures mathematical compliance with WCAG 2.x AA standards, it does not inherently guarantee perceptual accessibility or AAA compliance.

There’s a persistent myth that certain "mid-tone" backgrounds can result in both black and white text failing the WCAG 4.5:1 AA ratio. This is mathematically false under the WCAG 2.x relative luminance formula; one or both options will always pass. For instance, a medium blue like #2277d3 sits at a mathematical edge where both black and white text achieve a contrast ratio slightly above 4.58:1. contrast-color() will select the mathematically superior option.

The real issue lies in the perceptual limitations of the WCAG 2.x formula. The same #2277d3 background with black text, while mathematically passing AA, can be perceptually difficult to read. This highlights why APCA, which models human perception, is being developed. contrast-color() provides mathematical compliance, which is excellent for automated audits, but it doesn’t always translate to true perceptual accessibility.

For those aiming for the stricter WCAG AAA standard (7.0:1), a true "dead zone" exists. For backgrounds with luminance between approximately 10% and 30%, neither pure black nor pure white will achieve the 7:1 ratio. In such scenarios, contrast-color() cannot magically create compliance; it will simply return the "least bad" failing option.

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

2. Transitions Snap, Not Fade:
When animating properties like background-color with contrast-color() for the text, the output is a discrete value (black or white). This means that while the background might fade smoothly, the text color will "snap" rather than interpolate.

Consider a button that transitions from a white background to black on hover:

.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 */

The background fades over one second. However, because contrast-color() in Level 5 returns a discrete value, the text color cannot be interpolated. It will snap instantly when the background color crosses a certain threshold. This snap doesn’t occur at the midpoint of the animation. The WCAG 2.x relative luminance scale is non-linear. The mathematical tipping point, where black and white offer identical contrast, occurs at approximately 18% relative luminance. Consequently, during a white-to-black background fade, the text remains black for most of the transition, snapping to white only as the background becomes extremely dark. This abrupt change can be visually jarring.

The transition-behavior: allow-discrete property does not resolve this visual discontinuity. It only shifts the timing of the discrete snap to the 50% mark of the animation duration, rather than interpolating the color itself. For smooth text color transitions, developers must resort to alternative methods like color-mix() or manual crossfading techniques.

3. Tie Goes to White:
In the rare event that a background color results in an identical contrast ratio for both black and white text, the specification dictates a tiebreaker: white is chosen. While not a frequent issue, it’s a detail to be aware of when debugging palettes with perfect middle grays.

4. Gradients and Images Are Excluded:
The contrast-color() function is designed to accept a single, flat <color> value. It cannot process gradients or image URLs. Passing a gradient like linear-gradient(...) will result in a parse error. For backgrounds composed of images or complex gradients, developers will still need to rely on JavaScript or manual color selection for overlay text.

5. Transparent Colors Are Composited:
When a semi-transparent color is provided, the browser composites it against an assumed opaque background (typically white) before calculating contrast. This means the alpha channel is not ignored but is factored into the calculation after compositing. The result might deviate from expectations if one anticipates the function "seeing through" to the underlying content.

6. Windows High Contrast Mode:
In Windows High Contrast mode, the forced-colors: active media query takes precedence. The browser aggressively overrides author-defined colors, and contrast-color() defers to system colors like CanvasText. Forced system colors take over entirely, and developers do not need to write manual media queries to override their contrast logic, as the browser manages this hierarchy.

Synergizing with Other Color Functions

While the output of contrast-color() is limited to black or white, this output can be intelligently combined with other CSS color functions to create sophisticated component palettes from a single custom property.

Brand-Tinted Contrast with Relative Color Syntax:
Pure black or white text can sometimes feel stark. A more nuanced approach involves tinting the contrast text with a subtle shade of the background color. Kevin Hamer’s earlier exploration using OKLCH lightness and round() demonstrated how to approximate contrast-color()‘s binary decision in browsers lacking native support. The current approach takes this a step further:

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

  /* Pull L from contrast-color(), inject chroma and background hue */
  color: oklch(from contrast-color(var(--bg)) l 0.05 var(--bg-hue));

When contrast-color() returns white, l is 1. 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 imbues the text with personality while maintaining readability. It’s important to note that tweaking lightness and chroma can potentially push borderline contrast ratios into failure territory, necessitating a final check with an accessibility linter. This pattern also chains two modern features: contrast-color() and oklch(from ...). A @supports block testing for both is crucial for ensuring compatibility.

Softened Contrast with color-mix():
A simpler yet effective method involves mixing the sharp black/white output back into the background color:

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

  /* 80% contrast, 20% background for softer, readable text */
  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 approach allows a single custom property (--alert-color) to drive the text, border, and potentially other elements. It’s particularly useful for placeholder text, which should be legible but visually softer than the primary input text:

input 
  --bg: var(--input-bg);
  background: var(--bg);
  color: contrast-color(var(--bg));


input::placeholder 
  color: color-mix(in oklch, contrast-color(var(--bg)) 50%, var(--bg));

A 50% mix provides a muted yet legible placeholder that automatically adapts to the input’s background.

Theme-Aware Contrast with light-dark():
For applications supporting system light/dark mode, light-dark() offers a native solution:

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


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

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

Streamlining Codebases by Eliminating Libraries

The advent of contrast-color() allows for the removal of numerous JavaScript libraries previously employed for readable text color selection. Libraries like chroma-js, polished, and tinycolor2, which collectively account for significant bundle sizes, can be retired for this specific use case.

Library Size Functionality
chroma-js ~14 kB Color parsing, luminance calculation, 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 other advanced color manipulations, their role in providing basic readable text colors is now superseded by native CSS.

Beyond bundle size reduction, the performance benefits are substantial. JavaScript libraries execute on the main thread, competing with other critical tasks like layout and event handling. Every theme change or dynamic background update necessitates JS parsing, luminance computation, and DOM manipulation. contrast-color(), however, shifts this work to the browser’s highly optimized C++ style computation engine, which runs prior to painting. This can lead to a noticeable improvement in application responsiveness, especially in highly themed applications.

Furthermore, contrast-color() directly addresses the persistent issue of "hydration flash" in server-rendered applications. Without native CSS contrast calculation, the initial HTML rendered on the server might display with incorrect text colors until the client-side JavaScript hydrates and recalculates them. contrast-color() resolves the correct color during the initial paint, eliminating this visual glitch entirely.

A Look Back: What We Used To Do

The introduction of contrast-color() renders many older techniques obsolete:

  • Sass Era: Compile-time functions that checked lightness thresholds (e.g., lightness($bg) > 50%) were effective for static themes but failed for dynamic scenarios like user-selected colors or dark mode, as the output was hardcoded into the CSS file.
  • Variable Toggle Hack: This approach involved intricate calculations within CSS custom properties, often using calc() and luminance formulas. While functional, these methods were notoriously difficult to read, maintain, and prone to silent failures. Kevin Hamer’s OKLCH-based approximation represents the most refined version of this lineage, offering better perceptual alignment but still serving as a workaround for a now-native function.

contrast-color() consolidates all these approaches into a single, declarative function. Its future-proofing capability, allowing browsers to upgrade the underlying contrast algorithm without requiring code changes, ensures long-term compatibility as accessibility standards evolve.

The persistent 70% failure rate in WCAG contrast checks was never a reflection of developer apathy but rather the cumulative complexity of bridging the gap between intent and implementation. The reliance on libraries, build steps, runtime calculations, and the subsequent hydration flash created numerous points where accessibility could falter. contrast-color() aims to remove these barriers, making accessibility an inherent, cost-free aspect of web development. It doesn’t demand greater developer effort; it simply makes caring about contrast effortless.

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.