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.
Step 2: Map Each Pair to a Token
With five pairs, finding the tokens is a search of the stylesheet:
| Pair | Ratio | Token | Used on |
|---|---|---|---|
#8a94a6 on #ffffff | 3.9:1 | --text-muted | Dates, captions, breadcrumbs |
#d6336c on #f1f3f5 | 3.4:1 | --code-fg | Inline code |
#3b82f6 on #111827 | 3.6:1 | --link (dark) | Every link in dark theme |
#ffffff on #40c057 | 2.8:1 | --badge-success-bg | "New", "Stable" badges |
#adb5bd on #ffffff | 2.6:1 | --placeholder | Search 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.
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.
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
| Measure | Before | After | Tool |
|---|---|---|---|
color-contrast failures, light theme | 173 elements | 0 | axe-core 4.10 |
color-contrast failures, dark theme | 39 elements | 0 | axe-core 4.10 |
| Lighthouse accessibility score, home | 86 | 100 | Lighthouse 12 |
| Tokens changed | — | 5 | Stylesheet diff |
| Incomplete (manual review) items | 2 | 2, documented as passing | axe "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.
Related
- Parent: Accessibility for Static Sites — contrast among the other layers.
- Accessible Dark Mode Toggles for Static Sites — the theme where contrast most often breaks.
- Accessible Code Blocks in Documentation Sites — contrast for syntax tokens.
- Accessibility Testing with axe and pa11y in CI — the page scan in the pipeline.
- Keyboard Navigation and Focus Order on Static Sites — focus rings need 3:1 too.
Filed underFixesLighthouse