Fixing colour contrast violations flagged by axe-core
This page is part of the accessibility testing section of the Storybook isolation workflows guide, which covers auditing components where they render in isolation.
The specific problem: axe reports color-contrast on a handful of stories. The colours came from your design tokens, so the fix is not obvious — changing one component’s colour would break the system it belongs to.
Problem statement
color-contrast is the most-reported axe rule in a component library, and the least satisfying to fix, because the offending colour usually is not the component’s. It comes from a token, and the token is used in forty places.
The symptom is a small number of stories failing with a ratio just under the threshold — 4.2:1 where 4.5:1 is required — and no obvious owner for the change.
Root cause
WCAG 2.2 AA requires 4.5:1 for normal text, 3:1 for large text (at least 18.66px bold or 24px regular), and 3:1 for non-text elements such as focus rings and input borders. Those numbers are contrast ratios of relative luminance, not a perceptual scale, so a colour that looks obviously legible can measure below the line.
Tokens are usually chosen against one background. The failure appears when the same token is used against a second one — a muted grey that clears 4.5:1 on white sits at 3.9:1 on the surface colour, and only the story rendering it there reveals it.
Minimal reproduction
// Badge.tsx — white text on the warning tint
export const Badge = ({ tone, children }: BadgeProps) => (
<span className={`badge badge--${tone}`}>{children}</span>
);
/* the tint was chosen as a background, and white was assumed to work on it */
.badge--warning { background: #F4A0A0; color: #FFFFFF; } /* 2.01:1 — fails */
The badge is legible enough to pass a casual glance, which is exactly why the rule exists.
Step-by-step fix
1. Read the ratio and the pair, not just the rule name
npx test-storybook --url http://127.0.0.1:6006
# color-contrast (serious)
# Element has insufficient colour contrast of 2.01 (fg #FFFFFF, bg #F4A0A0)
# Expected contrast ratio of 4.5:1
What this does: gives you the two colours and the shortfall, which is enough to fix it without opening a contrast checker.
2. Fix the token, not the component
/* tokens.css — a paired ink for each tint, chosen against that tint */
:root {
--tone-warning-bg: #F4A0A0;
--tone-warning-ink: #5B2020; /* 7.1:1 on the tint above */
}
.badge--warning { background: var(--tone-warning-bg); color: var(--tone-warning-ink); }
What this does: names the pairing, so every future use of the tint gets a foreground that was chosen for it rather than assumed.
3. Check the pair in both themes
A token that passes in light mode can fail in dark mode, where the surface behind it is different.
// .storybook/test-runner.ts — run every story in both schemes
async preVisit(page) {
await page.emulateMedia({ colorScheme: process.env.SB_SCHEME === 'dark' ? 'dark' : 'light' });
}
What this does: makes the audit cover the theme you would otherwise ship untested. Run the suite twice, once per value of the variable.
4. Handle non-text contrast explicitly
/* focus rings and input borders need 3:1 against what is adjacent */
.input { border: 1px solid #767676; } /* 4.5:1 on white */
.input:focus-visible { outline: 2px solid #1F6B54; outline-offset: 2px; }
What this does: covers the requirement most component libraries miss, because it applies to the ring and the border rather than to any text.
5. Re-run and confirm the story flips
npm run test-storybook -- --url http://127.0.0.1:6006
# PASS Badge/Warning
Verification
Verify the change reached the whole system rather than one story: grep for the raw hex you replaced. Any remaining literal use of the tint with a hard-coded foreground is a place the token pairing has not been applied.
grep -rn "#F4A0A0" src --include="*.css" --include="*.tsx"
Edge cases and caveats
-
Disabled controls. WCAG exempts inactive components from the contrast requirement, and axe generally follows that. If a rule fires on a disabled control it is usually because the control is not marked disabled in a way the rule can see — check for
aria-disabledwithout the realdisabledattribute. -
Text over gradients or images. axe samples the background it can compute and may report a passing ratio for the sampled point while other regions fail. A solid scrim behind the text is the reliable fix; raising the text weight is not.
-
Placeholder text. Placeholders are text and are held to 4.5:1, which the browser default grey does not meet. Since placeholders are a poor substitute for labels anyway, the accessible fix is usually a visible label.
Auditing the palette instead of the stories
Fixing contrast one story at a time works, and it never finishes: each new component finds a new combination nobody checked. Auditing the palette itself converts an endless stream of findings into a bounded, one-off piece of work.
The audit is a matrix. List every surface colour a component can sit on — page background, card surface, raised surface, each brand tint — and every ink colour the system offers. Compute the ratio for each cell. The result is a small table that says, definitively, which inks are usable on which surfaces, and it is short enough to publish alongside the tokens.
Most palettes come out of that exercise with two findings. A muted grey that works on white and fails on the card surface, which is the single most common contrast bug in design systems. And a brand tint with no ink dark enough to sit on it, which is why white keeps getting used there despite failing.
The remedy in both cases is to stop treating inks and surfaces as independent choices. Exporting them as pairs — a surface token and the ink token chosen for it — makes the correct combination the easy one, and makes an incorrect combination something a reviewer can see in the diff rather than something axe finds three sprints later.
Publishing the matrix has a second benefit: it answers the designer’s question before it becomes an engineering ticket. A cell marked as failing is a decision made once, in the open, rather than re-litigated per component.
FAQ
Is 4.5:1 required for every piece of text?
No. 4.5:1 applies to normal text; large text — 18.66px bold or 24px regular and above — needs 3:1. Purely decorative text and text that is part of a logo are exempt. In a component library the practical consequence is that headings often pass at a colour body copy cannot use, which is a legitimate reason for a heading token and a body token to differ.
Can I disable the rule for one story rather than fixing the colour?
You can, with a per-story override, and it is the right call only when the finding is an artefact of testing in isolation — a component rendered with no page background, for instance. When the colours are the ones that ship, an override hides a real defect from the only check that would have caught it. Fix the token instead.
How do I stop contrast failures from coming back?
Encode the pairs. A token file that exposes background and foreground together, and lint or a unit test that computes the ratio of each declared pair, turns contrast into a build-time property rather than something rediscovered per story. The axe run then becomes a backstop for combinations the pairing did not anticipate rather than the primary defence.
Does the audit need to cover the dark theme separately?
Yes, and it usually finds more. A dark theme inverts lightness, so the surfaces change while ink tokens are often carried over unchanged, and a ratio that was comfortable in light mode can invert to a failure. Run the same matrix against the dark surfaces and expect the two results to disagree — that disagreement is exactly what a single shared ink token hides.
Related
- Accessibility Testing — the parent section on auditing components in isolation
- Running axe-core in Storybook Play Functions — turning an audit into a per-story assertion
- Configuring the Storybook a11y Addon for WCAG Checks — scoping which rules and WCAG tags run
- Test-Runner Automation — the postVisit hook that makes these audits a gate