Fixing Low Color Contrast Flagged by Lighthouse

"Background and foreground colors do not have a sufficient contrast ratio" is the most common accessibility failure on the web, and static sites are no exception. It is also the most fixable, for a reason specific to how static sites are built: their colours come from a small set of CSS custom properties or a Tailwind config, so a failure reported on 200 elements is usually one token used 200 times. Fix the token, rebuild, and every page is fixed at once.

This guide works through the contrast failures on the reference documentation site from Accessibility for Static Sites: 212 failing elements per the axe run across 60 sample pages, which traced back to five tokens. It shows how to find the token behind each failure, how to choose a replacement that keeps the brand, and how to stop new failures shipping.

Prerequisites

  • The site's colour tokens in one place: CSS custom properties, a Sass map, or tailwind.config / @theme.
  • axe-core through Playwright, or at least Lighthouse in Chrome DevTools.
  • A contrast calculator. Chrome DevTools' colour picker shows the ratio and the AA and AAA lines directly.

Step 1: Collect Every Failure, in Every State

Lighthouse's accessibility audit tests one page, in one theme, at one viewport, with menus closed. It reported 17 contrast failures on the home page and missed the dark theme entirely. A Playwright script that runs axe's color-contrast rule on 60 pages in both themes found the real total:

import AxeBuilder from '@axe-core/playwright';

for (const theme of ['light', 'dark']) {
  await page.addInitScript(t => localStorage.setItem('theme', t), theme);
  await page.goto(url);
  const r = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
  for (const v of r.violations) for (const n of v.nodes)
    rows.push({ url, theme, target: n.target.join(' '), data: n.any[0].data });
}

Each node's data includes the foreground colour, background colour and measured ratio. Group the rows by foreground and background pair, not by page.

Number of failing elements per foreground and background colour pair Horizontal bars. Muted grey text on white accounted for 88 failing elements. Inline code pink on pale grey 51. Dark theme link blue on near black 39. Badge text white on light green 21. Input placeholder grey on white 13. Failing elements grouped by colour pair Muted text on white 88 Inline code on pale grey 51 Links on dark background 39 Badge text on green 21 Placeholder on white 13
212 failing elements across 60 pages and two themes collapsed into five colour pairs.

Step 2: Map Each Pair to a Token

With five pairs, finding the tokens is a search of the stylesheet:

PairRatioTokenUsed on
#8a94a6 on #ffffff3.9:1--text-mutedDates, captions, breadcrumbs
#d6336c on #f1f3f53.4:1--code-fgInline code
#3b82f6 on #1118273.6:1--link (dark)Every link in dark theme
#ffffff on #40c0572.8:1--badge-success-bg"New", "Stable" badges
#adb5bd on #ffffff2.6:1--placeholderSearch input placeholder

The dark-theme link is worth noting: the light theme used #1d4ed8, which passes at 6.7:1 on white. Whoever built the dark theme reused a lighter blue that looked bright enough but measured 3.6:1 on #111827. Nobody had run a check in dark mode.

Step 3: Choose Replacements That Keep the Brand

Changing hue makes a design look different; changing lightness usually does not. Converting each failing colour to OKLCH and lowering (or, on dark backgrounds, raising) the lightness until the ratio passes keeps the colour recognisable:

:root {
  --text-muted: oklch(0.52 0.02 255);    /* was 0.66, now 5.1:1 on white */
  --code-fg: oklch(0.50 0.19 0);         /* was 0.58, now 5.6:1 on #f1f3f5 */
  --badge-success-bg: oklch(0.52 0.14 145); /* white text now 4.8:1 */
  --placeholder: oklch(0.58 0.01 255);   /* now 4.6:1 */
}
[data-theme='dark'] {
  --link: oklch(0.78 0.11 250);          /* was 0.62, now 6.2:1 on #111827 */
}

Aim a little above the threshold — 4.6 to 5:1 rather than exactly 4.5:1 — because antialiasing and rounding in different tools disagree by a few hundredths. Placeholder text is a special case: WCAG requires it to meet 4.5:1 if it conveys information, and the better fix is a visible label so the placeholder is not doing that job at all. The search input got a visible label as well as the darker placeholder.

Contrast ratio of each failing token before and after the change Paired bars. Muted text 3.9 to 5.1. Inline code 3.4 to 5.6. Dark theme links 3.6 to 6.2. Badge text 2.8 to 4.8. Placeholder 2.6 to 4.6. All after values are above the 4.5 to 1 threshold for normal text. Contrast per token before and after Before After Muted text 3.9:1 5.1:1 Inline code 3.4:1 5.6:1 Links (dark theme) 3.6:1 6.2:1 Badge text 2.8:1 4.8:1 Placeholder 2.6:1 4.6:1
Same hue, adjusted lightness; designers signed off on all five without changing the palette's character.

Step 4: Handle the Cases Tools Cannot Measure

Automated tools give up on text over images, gradients and semi-transparent overlays; axe lists them under "incomplete". The reference site had two: hero text over a photograph on the home page and white text on a gradient button. For the hero, a solid scrim behind the text at 60% opacity gave a measurable worst case — the lightest pixel of the image under the text, sampled in DevTools, against white text measured 7.2:1 through the scrim. The button gradient was checked at its lightest end, which passed at 4.7:1. These need a person to check and a note in the design system saying why they pass.

Step 5: Stop Regressions in CI

A contrast check belongs in CI in two forms. The axe run above, on a sample of pages in both themes, catches failures in real pages. A token test catches them earlier, in the design system itself, by computing the ratio of every text token against every background it is allowed on:

import { wcagContrast } from 'culori';
const pairs = [['--text', '--surface'], ['--text-muted', '--surface'], ['--link', '--surface'],
               ['--code-fg', '--code-bg']];
for (const theme of ['light', 'dark']) for (const [fg, bg] of pairs) {
  const r = wcagContrast(tokens[theme][fg], tokens[theme][bg]);
  if (r < 4.5) throw new Error(`${theme}: ${fg} on ${bg} is ${r.toFixed(2)}:1`);
}

The token test runs in under a second and fails before a single page is built.

Where the token test and the page scan run Four steps. A pull request changes CSS tokens. The token test computes every allowed foreground and background pair in both themes in under a second and fails below 4.5 to 1. The site builds. axe scans 60 pages in both themes and fails on any color-contrast violation. Two contrast checks in the pipeline PR changes tokens CSS or Tailwind config Token test all pairs, both themes, <1 s Build static HTML axe page scan 60 pages × 2 themes
The token test catches design-system changes; the page scan catches one-off colours added in components.

Contrast Beyond Text

Text is what the color-contrast rule checks, but WCAG 2.2 also requires 3:1 for the parts of the interface a reader needs to see to use it: input borders, checkbox outlines, the focus ring, the current-page marker in the sidebar, and chart lines that carry meaning. None of these are checked by axe or Lighthouse, so they need the token test and a person.

On the reference site, three non-text tokens failed. The input border #dee2e6 measured 1.3:1 on white — the search box was almost invisible to low-vision readers, who reported "I can't find the search" in a usability session. The sidebar's current-page indicator was a 2 px bar in a pale blue at 2.1:1, so the only reliable cue for the current page was bold text. And the dark theme's table borders were 1.4:1, which made wide comparison tables hard to follow across a row. The fixes were a border token at 3.4:1, a current-page bar in the link colour at 6.7:1, and table borders at 3.1:1 in the dark theme. These were added to the token test as a second list of pairs with a 3:1 threshold, so that interface colours are held to their own standard rather than the text standard, which would have forced borders heavier than the design needed.

Measured Impact

MeasureBeforeAfterTool
color-contrast failures, light theme173 elements0axe-core 4.10
color-contrast failures, dark theme39 elements0axe-core 4.10
Lighthouse accessibility score, home86100Lighthouse 12
Tokens changed—5Stylesheet diff
Incomplete (manual review) items22, documented as passingaxe "incomplete"

Pitfalls & Rollback

  • Fixing per element. Overrides in components hide the token problem and multiply.
  • Testing only the light theme. Lighthouse will not switch themes for you.
  • Hitting exactly 4.5:1. Leave a margin for tool disagreement.
  • Using placeholder text as a label. Add a real label as well.
  • Rollback: the change is five token values; reverting them restores the old colours, and the token test would fail the revert, so a deliberate rollback has to adjust the test's threshold in the same commit.

Conclusion

Contrast failures on a static site are a token problem. Collect every failure in both themes, group by colour pair, map pairs to tokens, adjust lightness rather than hue, and test the tokens themselves in CI. On the reference site, five token changes cleared 212 failures and the Lighthouse score went from 86 to 100.

FAQ

What contrast ratio does WCAG AA require?

4.5 to 1 for normal text and 3 to 1 for large text, which is at least 24 CSS pixels, or 18.66 pixels if bold. User interface components and meaningful graphics, such as input borders and focus indicators, need 3 to 1 against adjacent colours. Disabled controls and pure decoration are exempt.

Why does Lighthouse report contrast problems that axe does not, or the other way round?

Lighthouse runs a subset of axe rules on one page load at one viewport, so it misses text that only appears in other states, such as hover, a dark theme or an expanded menu. axe run through Playwright can test each state separately. Both skip text over background images and gradients, which they report as needing review rather than as failures.

How do I fix contrast without changing the brand colour?

Keep the hue and change the lightness. A brand blue that fails as text on white usually passes when darkened by 8 to 15 percent lightness in a perceptual space such as OKLCH, and the result still reads as the brand colour. Keep the original for large headings, buttons with white text if it passes there, and illustrations.

Do syntax highlighting colours need to meet contrast requirements?

Yes. Code is text, and token colours are read just like prose. Many popular highlighter themes have comment and punctuation colours between 2.5 and 4 to 1. Choose a theme that passes or override the failing token colours; the accessible code blocks guide in this topic lists the overrides used on the reference site.

Filed underFixesLighthouse