Skip to main content

Color Contrast WCAG 2.2: A Developer's Fix Guide (CSS)

Benjamin Morton

Low-contrast text is the most common accessibility failure on the web, and it isn't close. In 2026, 83.9% of the top million home pages had low-contrast text, up from 79.1% a year earlier and the single largest mover in the whole dataset (WebAIM Million, 2026). The average failing page carried 34 separate instances of it.

Here's the good news buried in that number: contrast is also the most fixable thing on the list. It's fully defined by a formula, it's caught by every automated scanner, and most of the time the fix is one changed value in a design token. This guide covers the exact ratios WCAG 2.2 asks for, how the ratio is calculated, and the CSS to get from fail to pass, including the new contrast-color() function and the older one people keep confusing it with.

TL;DR: Body text needs a 4.5:1 contrast ratio against its background; large text (≥24px, or ≥18.66px bold) needs 3:1; UI borders, icons and focus indicators need 3:1 (W3C WCAG 2.2). Low-contrast text is the web's most common failure, on 83.9% of home pages (WebAIM, 2026). Fix it with design tokens checked in every theme. The new CSS contrast-color() helps but only returns black or white, so it is not a guaranteed pass.

What Contrast Ratio Does WCAG 2.2 Require?

Three numbers cover almost everything. For Level AA, the target nearly every accessibility law references, normal text needs 4.5:1 against its background, large text needs 3:1, and non-text elements like borders and icons need 3:1 (W3C WCAG 2.2, 2023).

What Contrast Ratio Does Each Rule Need? Normal text (AA) 4.5:1 Large text (AA) 3:1 UI & graphics (AA) 3:1 Normal text (AAA) 7:1 Higher is stricter. AA is the legal target; AAA (7:1) is enhanced and optional. Source: W3C WCAG 2.2, success criteria 1.4.3, 1.4.6 and 1.4.11

"Large" has a precise definition, and it's about size, not importance. Text counts as large at 18 point and up, which is roughly 24px, or 14 point bold, roughly 18.66px (Understanding 1.4.3, 2023). Below those sizes, 4.5:1 applies however bold the weight looks.

Here's the full set of contrast rules, so you can see where AAA and non-text fit:

Success criterion

Level

Normal text

Large text

1.4.3 Contrast (Minimum)

AA

4.5:1

3:1

1.4.6 Contrast (Enhanced)

AAA

7:1

4.5:1

1.4.11 Non-text Contrast

AA

3:1 (UI and graphics)

N/A

Citation capsule: WCAG 2.2 requires a 4.5:1 contrast ratio for normal text and 3:1 for large text (≥18pt, or ≥14pt bold) at Level AA, rising to 7:1 and 4.5:1 at Level AAA (W3C WCAG 2.2, 2023). A separate criterion, 1.4.11 Non-text Contrast, requires 3:1 for user-interface components and meaningful graphics. Low-contrast text is the most common failure on the web, affecting 83.9% of home pages (WebAIM Million, 2026).

How Is the Contrast Ratio Actually Calculated?

It's a ratio of luminance, not a subjective judgement. WCAG defines contrast as (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter colour and L2 the darker (W3C, 2023). Relative luminance itself weights the channels by how bright the eye perceives them: L = 0.2126·R + 0.7152·G + 0.0722·B, after each channel is linearised from its sRGB value.

You will almost never compute this by hand, and you shouldn't. But knowing the shape of it explains two things. First, green dominates the sum, which is why a green and a red of the "same" brightness can still fail. Second, the ratio maxes out at 21:1 (pure black on pure white) and bottoms out at 1:1 (a colour on itself).

The 4.5:1 threshold wasn't picked at random. W3C set it to compensate for the contrast sensitivity loss of roughly 20/40 vision, about what you'd expect from moderate low vision or normal ageing (Understanding 1.4.3, 2023).

Worth knowing: this formula is luminance-only, and it has real blind spots. It doesn't account for font weight, size beyond the large-text cutoff, or how differently thin text reads on modern high-density screens. The proposed successor, APCA (a perceptual contrast algorithm), was meant to replace it in WCAG 3.0 but was pulled from the July 2023 draft and is still exploratory (Adrian Roselli, 2026). So despite its flaws, 4.5:1 is the operative rule and stays that way until WCAG 3.0 lands, which won't be for years. Build to the formula we have.

Don't Forget Non-Text Contrast

This is the rule teams miss most. Success criterion 1.4.11 requires 3:1 contrast for anything you need to see that isn't text: form input borders, focus indicators, toggle states, icons that carry meaning, and the lines on a chart (W3C WCAG 2.2, 2023). A pale grey input border on white is a classic failure, because you can't tell where the field is.

Focus indicators are the most important case, because keyboard users depend on them entirely. A focus ring has to clear 3:1 against whatever sits next to it:

/* Non-text contrast: the focus ring must be 3:1 against adjacent colours */
:focus-visible {
  outline: 3px solid #1a5fb4; /* a strong blue, well above 3:1 on white */
  outline-offset: 2px;
}

The exceptions are narrow. Inactive (disabled) components are exempt, as are pure logos and anything whose meaning is also given in adjacent text (Understanding 1.4.11, 2023). Everything else that carries information needs the 3:1.

How Do You Fix Low Contrast With CSS?

You fix it once, in your design tokens, not scattered across components. The overwhelming majority of contrast failures are a single muted colour reused everywhere, usually a grey that looked fine on the designer's monitor. Change the token and every instance passes.

Take the most common offender, placeholder-grey text:

/* ❌ Fails 1.4.3: #999 on #fff is only 2.85:1 */
:root { --text-muted: #999999; }

/* ✅ Passes: #767676 on #fff is 4.54:1 */
:root { --text-muted: #767676; }

#767676 is worth memorising: it's the lightest pure grey that clears 4.5:1 on white. Anything lighter fails for body text.

Low-Contrast Text on Home Pages 79.1% 2025 83.9% 2026 The web's most common failure, and the largest single mover in 2026. Source: WebAIM Million, 2026

For sites with light and dark themes, the trap is defining a colour once and assuming it works in both. It won't. Use light-dark(), which has been Baseline since 2024, and check each value against its own background:

:root { color-scheme: light dark; }

body {
  /* Each value verified to 4.5:1 in its own theme */
  color: light-dark(#1a1a1a, #e0e0e0);
  background: light-dark(#ffffff, #121212);
}

The workflow is the same every time: paste both colours into the WebAIM Contrast Checker, or open Chrome DevTools and read the ratio straight off the colour picker, then adjust the token until it passes. Then gate it, so it can't regress. Contrast is one of the checks axe-core catches reliably, which makes it a perfect candidate for a CI accessibility gate as part of a complete testing workflow.

One caveat from running a scanner on real sites: automated contrast checks are reliable on solid backgrounds and blind on tricky ones. Text over a background image, a gradient, or a semi-transparent overlay often can't be evaluated, so the tool skips it rather than failing it. A clean automated report means "no detectable contrast errors", not "every bit of text passes". Hero text over a photo is exactly where a human still has to look.

The New contrast-color() Function, and Its Trap

CSS can now pick a readable text colour for you, but there's a catch, and there's a near-identical function that never shipped. The working one is contrast-color(): you give it a colour, and it returns black or white, whichever contrasts more. It reached Baseline "newly available" in April 2026, shipping in Chrome and Edge 147, Firefox 146, and Safari 26 (MDN, 2026).

.badge {
  background: var(--brand);
  /* Returns black or white, whichever contrasts more with --brand */
  color: contrast-color(var(--brand));
}

That's genuinely useful for a badge whose background is set at runtime. But here's the trap you have to understand: contrast-color() only ever returns black or white. If your brand colour is a mid-tone, a royal blue, say, neither black nor white text may actually clear 4.5:1 against it. The function will still return one of them. So contrast-color() is a helper, not a guarantee of WCAG conformance. You still have to check the result.

And watch the name. Plenty of tutorials show color-contrast() (note the word order), an earlier function that picked the best option from a list of candidates. That one never shipped to a stable browser. It appeared briefly in a Safari preview in 2021, then was postponed to a future CSS spec (caniuse, 2026). If a snippet tells you to use color-contrast() today, it's out of date; the shipping function is contrast-color().

How Do You Respect a User's Contrast Preference?

Some users tell the browser they need more contrast, and you can honour that. The prefers-contrast media feature (Baseline since 2022) exposes it, so you can swap in stronger tokens when someone asks for them:

@media (prefers-contrast: more) {
  :root { --text-muted: #595959; } /* ~7:1 on white */
}

Separately, Windows High Contrast Mode replaces your palette with the user's own chosen colours through forced-colors. Don't fight it. Use system colour keywords so your borders and controls follow the user's theme:

@media (forced-colors: active) {
  .card { border: 1px solid CanvasText; }
}

The principle behind both: contrast is partly the user's call, and your job is to not get in the way when they've already made it.

What About Dark Mode?

Dark mode has its own contrast pitfalls, and inverting your light theme isn't the fix. The first mistake is pure white text on a pure black background. That maximises the ratio at 21:1, but very bright text on true black causes halation, a glow or smear that makes text harder to read, worst for people with astigmatism (Smashing Magazine, 2025).

The practical fixes are small. Use an off-black surface like #121212 and off-white text like #e0e0e0 rather than the extremes. Re-derive each colour for the dark theme instead of flipping the light one, because a grey that passes on white often fails on dark. And re-check your focus indicators and state colours in both themes, since a focus ring tuned for a white page can vanish on a dark one. Every token, every theme, checked on its own.

Frequently Asked Questions

What contrast ratio does WCAG require?

Level AA needs 4.5:1 for normal text and 3:1 for large text (≥18pt, or ≥14pt bold), plus 3:1 for non-text elements like borders and icons (W3C WCAG 2.2, 2023). Level AAA raises text to 7:1 and 4.5:1. AA is the level nearly every accessibility law references.

What is the difference between 4.5:1 and 3:1?

4.5:1 applies to normal body text; 3:1 applies to large text and to non-text elements. Text counts as large at 18pt (about 24px), or 14pt bold (about 18.66px) (Understanding 1.4.3, 2023). Bigger, heavier shapes stay legible at a lower ratio, so the requirement relaxes from 4.5:1 to 3:1.

Does contrast-color() make my colours WCAG compliant?

Not on its own. contrast-color() returns black or white, whichever contrasts more with a given colour (MDN, 2026). Against a mid-tone background, neither black nor white may reach 4.5:1, and the function still returns one. Treat it as a helper and verify the result with a contrast checker.

Why is low contrast so common if it's easy to fix?

Because it's decided in design, not code. Low-contrast text is on 83.9% of home pages (WebAIM Million, 2026), usually a brand palette or a muted grey chosen before any scanner runs. A linter can flag it, but it can't overrule a brand colour, so it survives into production more than failures that live purely in markup.

How do I test colour contrast?

Use the WebAIM Contrast Checker for individual pairs, Chrome DevTools for live values off the colour picker, and axe or a similar scanner for whole-page checks. Automated tools catch solid-background contrast reliably but skip text over images or gradients, so those still need a manual look.

The Bottom Line

Colour contrast is the web's most common accessibility failure, on 83.9% of home pages and still rising (WebAIM Million, 2026), and it's also the most tractable. The rules are three numbers: 4.5:1 for body text, 3:1 for large text, 3:1 for the borders, icons and focus rings covered by non-text contrast. The formula behind them is luminance-only and imperfect, but it's the standard until WCAG 3.0 arrives, which is years away.

Fix it where it lives: in a handful of design tokens, checked against their real backgrounds in every theme, then gated in CI so it can't creep back. Lean on contrast-color() where a colour is dynamic, but verify its output rather than trusting it. Contrast is one of the six failures behind most detected errors, and it's the one you can clear this afternoon. Start by scanning your own pages to see how many instances you're carrying.


Sources