testing and automating accessibility
Catching Color Contrast Failures with axe-core
Color contrast is the single most common WCAG failure in production, and it's also one of the few that automation handles well—when the background is a solid color. axe-core's color-contrast rule computes the ratio between foreground text and its resolved background and fails anything below the threshold. The friction starts when text sits over a gradient, an image, or a semi-transparent overlay: there axe returns incomplete ("needs review") rather than a verdict, and teams routinely misread that as a pass. This guide, part of Automated Accessibility Testing with axe-core, explains exactly how the rule decides, why it punts on certain backgrounds, and how to fix the underlying design tokens.
Prerequisites
- axe-core wired into your dev loop or test suite (browser extension,
@axe-core/react, jest-axe, or Playwright). - A theming layer built on CSS custom properties—see accessible color contrast & theming for the token architecture this guide assumes.
- Familiarity with the relevant criteria:
1.4.3 Contrast (Minimum)for text and1.4.11 Non-text Contrastfor UI components and focus indicators.
How the color-contrast Rule Works
The color-contrast rule selects rendered text nodes, then for each one it resolves the computed foreground color and walks up the DOM to find the effective background color. It calculates relative luminance for both and produces a ratio, which it tests against the WCAG thresholds:
- 4.5:1 for normal text (
1.4.3 Contrast (Minimum)). - 3:1 for large text — 24px, or 18.66px (14pt) bold.
The rule only fires on wcag2aa tag runs and above, so confirm your runOnly includes it:
import axe from 'axe-core';
const results = await axe.run(document, {
runOnly: { type: 'tag', values: ['wcag2aa', 'wcag21aa'] },
});
// Separate confident failures from "you need to look" results.
const contrastViolations = results.violations.filter((v) => v.id === 'color-contrast');
const contrastReview = results.incomplete.filter((v) => v.id === 'color-contrast');
console.error('definite contrast failures:', contrastViolations.length);
console.warn('contrast needs manual review:', contrastReview.length);
Each failing node comes with the data axe used, which is what makes the result actionable:
contrastViolations.forEach((v) => {
v.nodes.forEach((node) => {
const data = node.any[0]?.data; // { fgColor, bgColor, contrastRatio, expectedContrastRatio }
console.log(node.target, data?.contrastRatio, 'needs', data?.expectedContrastRatio);
});
});
Note the rule covers text, not borders or icons. 1.4.11 Non-text Contrast—input borders, focus rings, icon glyphs—is not evaluated by color-contrast; axe has only partial automated coverage there, so those almost always need manual checking.
Why Overlays, Gradients, and Images Return Incomplete
axe can only compute a ratio when it can determine a single, solid background color. It returns undefined—classified as incomplete—whenever the background is ambiguous:
- Background images. axe can't sample pixels behind the text, so it can't know the local luminance under each glyph.
- Gradients. The contrast varies across the text run; there's no single background value to test against.
- Semi-transparent overlays. When a foreground or an intermediate layer uses
rgba()/opacity, the effective composited color depends on everything beneath it, which axe doesn't flatten reliably. - Overlapping/elevated elements. If another positioned element sits between the text and its background, axe can't be sure which one actually paints behind the text.
This is correct, conservative behaviour: axe refuses to emit a false pass or a false fail. The danger is purely procedural—if your reporting only counts violations, these nodes vanish. Always surface incompletes:
// A reporting helper that never lets contrast incompletes disappear.
function summariseContrast(results: import('axe-core').AxeResults) {
return {
failed: results.violations.filter((v) => v.id === 'color-contrast').length,
review: results.incomplete
.filter((v) => v.id === 'color-contrast')
.flatMap((v) => v.nodes.map((n) => n.target.join(' '))),
};
}
// review[] is your manual checklist: every one of these is a hero banner,
// gradient CTA, or image overlay a human must verify.
For the common case—white text on a hero image—the fix is to guarantee a worst-case background regardless of the image: a solid scrim or a text-protection gradient with enough opacity that the darkest text-over-background pairing still clears 4.5:1. Once a solid effective color exists, axe can evaluate it.
Fixing Design-Token Contrast
Most contrast failures trace back to a small number of token pairings reused everywhere—muted text on tinted surfaces, brand color on white. Fix the token, fix every instance. Resolve pairings against the WCAG luminance algorithm at the source:
// contrast.ts — same algorithm axe and WCAG 2.2 use.
function luminance(hex: string): number {
const ch = hex.replace('#', '').match(/.{2}/g)!.map((c) => {
const v = parseInt(c, 16) / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
}
export function ratio(fg: string, bg: string): number {
const [a, b] = [luminance(fg), luminance(bg)];
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
// Guard your design tokens in a unit test so a regression can't merge.
const TOKENS = {
'--text-muted on --surface': ratio('#6b7280', '#ffffff'), // ~4.83 — passes
'--brand on --surface': ratio('#2563eb', '#ffffff'), // ~4.55 — passes, barely
};
// tokens.contrast.test.ts
import { ratio } from './contrast';
it('muted text clears 1.4.3 Contrast (Minimum) on surface', () => {
expect(ratio('#6b7280', '#ffffff')).toBeGreaterThanOrEqual(4.5);
});
Two token-level rules that prevent most regressions:
- Never express a contrast difference with
opacity. Lowering a token's alpha changes the composited luminance unpredictably and fails the WCAG calculation. Define an explicit darker/lighter token instead. - Test tokens against every surface they render on, including the dark theme, not just the light default. A pair that passes on
#ffffffcan fail on a tinted card.
For the full token and theming architecture this slots into, see accessible color contrast & theming.
How to Verify
Contrast is verifiable both programmatically and manually—do both, because each catches what the other misses.
Programmatic (catches solid-background failures):
// In jest-axe or Playwright, assert zero contrast violations AND zero
// unaddressed incompletes for the components under test.
const { violations, incomplete } = await axe.run(container, {
runOnly: { rules: ['color-contrast'] },
});
expect(violations).toEqual([]);
// Fail the build if a contrast incomplete appears on a component that
// shouldn't have one — it means an overlay/gradient slipped in unreviewed.
expect(incomplete.filter((i) => i.id === 'color-contrast')).toEqual([]);
Manual (catches everything axe marked incomplete):
- Use the axe DevTools or browser eyedropper to sample the actual rendered background under text on hero images, gradients, and overlays at the worst-case pixel. Compute the ratio against that sample.
- Toggle the OS into dark mode and high-contrast/
forced-colorsmode and re-check the same surfaces—token pairings that pass in light can fail elsewhere. - For
1.4.11 Non-text Contrast, manually verify input borders, icon contrast, and the:focus-visiblering clear 3:1 against their adjacent colors; axe won't reliably do this for you.
A component is clear only when programmatic violations are zero and every contrast incomplete has been sampled and confirmed by hand.
Common a11y Mistakes
- Counting incompletes as passes. A hero with white text on a photo reports
incomplete, notpass. If your gate only checksviolations, it ships unverified. Surface and review every contrast incomplete. - Using
opacityfor "muted" text. It changes composited luminance and fails WCAG math. Use an explicit color token. - Testing tokens against the light theme only. Re-run for dark and high-contrast themes; the same pairing can flip from pass to fail.
- Forgetting non-text contrast.
color-contrastignores borders, icons, and focus rings.1.4.11 Non-text Contrastneeds manual verification. - Fixing the instance, not the token. Patching one component's color leaves the same failing pairing live everywhere else it's used. Fix at the token source and test it.
Conclusion
axe-core's color-contrast rule is a reliable gate for solid-background text and a precise pointer to ambiguous cases via its incomplete results. The discipline that makes it effective is refusing to treat "needs review" as "passed": every gradient, overlay, and image-backed text run is a manual checkpoint. Fix contrast at the design-token layer, guard those tokens with unit assertions, and verify the rest with an eyedropper and dark/high-contrast modes—then your automated run and your real users agree.
Frequently Asked Questions
Why did axe mark my hero text as "incomplete" instead of failing it?
Because the text sits over a background axe can't reduce to a single solid color—an image, gradient, or semi-transparent overlay. It returns undefined rather than guess, which is classified as incomplete. Sample the worst-case background pixel manually and compute the ratio against 1.4.3 Contrast (Minimum); add a solid scrim if it falls short.
Does the color-contrast rule check icons, borders, and focus rings?
No. The color-contrast rule only evaluates text. 1.4.11 Non-text Contrast—UI component borders, icon glyphs, and focus indicators at 3:1—has only partial automated coverage in axe, so verify those by hand with an eyedropper.
How do I fail a build on contrast issues but still allow reviewed exceptions?
Assert violations for color-contrast is empty, and assert that contrast incomplete entries only appear on components you've explicitly reviewed (record those as documented exceptions). That way a new unreviewed gradient or overlay still breaks the build.
Why does using opacity for muted text fail contrast checks?opacity and alpha channels composite the text against whatever is behind it, changing the effective luminance unpredictably. The WCAG ratio is computed from solid colors, so axe (and real assistive-tech users) see a different value than you intended. Define an explicit darker token instead.
Related guides
- Automated Accessibility Testing with axe-core — the guide on the rules engine.
- Accessible Color Contrast & Theming — the token architecture these fixes build on.
- Writing Custom axe-core Rules — encode project-specific contrast checks into the engine.