WCAG 2.2 Success Criterion 1.4.3 requires that visual text and images of text have a contrast ratio of at least 4.5:1 against their background, with two exceptions: large-scale text (18pt or 14pt bold) needs only 3:1, and incidental text or logos are exempt. The W3C normative text reads:
“The visual presentation of text and images of text has a contrast ratio of at least 4.5:1, except for the following: large text, incidental, logotypes.”
In practice 1.4.3 is the single most common automated finding on production sites, because it slips in through hover states, placeholder text, secondary greys, and brand-coloured CTAs that look fine on a designer’s calibrated 5K display but fail on a budget Android in daylight.
The failing pattern
A typical failure looks like this:
:root {
--text-muted: #9ca3af; /* 2.85:1 on #ffffff — FAILS 1.4.3 */
}
.help-text { color: var(--text-muted); }
.placeholder { color: #d1d5db; } /* 1.61:1 — FAILS */
.brand-cta {
background: #38bdf8; /* sky-400 */
color: #ffffff; /* 2.49:1 — FAILS for normal text */
}
The first issue is reaching for the lightest grey that “still looks visible”. This is exactly what 1.4.3 is designed to catch: text that the designer can see on their workstation but a real user with mild cataracts, glare, or a cheap screen cannot.
The fix — tokenise contrast intent, not aesthetic
Replace ad-hoc colour picks with two columns of tokens — one for backgrounds, one for foregrounds — and constrain them so any combination your design system permits passes 4.5:1.
:root {
/* Backgrounds */
--bg-base: #ffffff;
--bg-subtle: #f8fafc;
/* Foregrounds — tested at 4.5:1+ on bg-base */
--fg-default: #0f172a; /* 19.5:1 — AAA */
--fg-muted: #334155; /* 10.4:1 — AAA */
--fg-subtle: #475569; /* 7.6:1 — AAA */
--fg-accent: #0369a1; /* 7.0:1 — AAA */
}
For Tailwind, lock the text- utility classes to the same constrained set
via theme.extend.colors. Reject text-gray-400 in code review by lint:
// .eslintrc.js
{
"rules": {
"no-restricted-syntax": ["error", {
"selector": "Literal[value=/text-(gray|slate|zinc)-(300|400|500)/]",
"message": "Use design-system foreground tokens, not raw Tailwind greys."
}]
}
}
For brand CTA buttons, treat the brand colour as a background and pick a
foreground that meets 4.5:1 against it. If your brand teal is #38bdf8 and
white text fails, your options are: darken the background to #0369a1 (passes
on white text), or thicken/upcase the label to qualify as “large” so 3:1
applies.
How to test
Automated (axe-core) — run the color-contrast rule against every
page in CI:
import { test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("homepage 1.4.3", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withRules(["color-contrast", "color-contrast-enhanced"])
.analyze();
if (results.violations.length) {
throw new Error(JSON.stringify(results.violations, null, 2));
}
});
Manual — the browser DevTools “Accessibility” pane shows the contrast ratio against the computed background, including for elements that inherit a translucent overlay. Also valid: TPGi Colour Contrast Analyser for ad-hoc checks; WebAIM Contrast Checker for design hand-off conversations.
Multi-tool comparison — axe-core, Pa11y, Lighthouse, and IBM Equal Access all implement the WCAG contrast algorithm, but their handling of gradients, semi-transparent overlays, and CSS background images differs. For a contested element, run two of them and reconcile.
When this is hard
- Text over images. axe-core defaults to assuming the worst-case pixel under the text run; designers often want to override with a sampled mean. Don’t — the real user might be stretching the viewport, scrolling, or using a custom theme that shifts the image.
- Dark mode. Run the contrast suite under both
prefers-color-schememodes. A token that passes in light mode at 7.6:1 may pass at only 4.6:1 in dark mode — still AA, but no margin against future tweaks. - Disabled controls. WCAG 2.2 explicitly exempts disabled UI from 1.4.3, but 1.4.11 (non-text contrast) still applies to the focus indicator. Don’t use exemption to justify near-invisible disabled states.
- Brand override. Marketing CTAs sometimes ship with a brand colour that fails. Escalate: either get a brand-system update (light/dark variants that pass), or accept that the brand colour is a button background with a white or near-black foreground, not a text colour.
Cross-links
- Reference text: WCAG 1.4.3 explained on w3c.wiki — the normative reference, with worked examples of edge cases.
- Related W3C ref: WCAG 1.4.11 non-text contrast (icon buttons, focus rings, form borders).
- Testing infrastructure: How to wire axe-core into CI.
- Adjacent SC: How to fix WCAG 2.4.7 focus visible.
If your test suite reports zero contrast violations but reviewers still flag unreadable text, run the page through a real screen at low brightness in direct sunlight before debating the algorithm. WCAG numbers approximate; the human eye is the actual acceptance criterion.