How to fix WCAG 1.4.3 contrast

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.”

WCAG 2.2 SC 1.4.3 (W3C)

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-scheme modes. 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.

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.