Skip to main content
October 1, 2026 · colors · 9 min read

Color Contrast and WCAG — the Field Guide

WCAG contrast ratios, decoded. What 4.5:1 actually buys you, where AAA is overkill, and which generators get the math right.

Last updated October 1, 2026 · 9 min read

The one opinion worth defending up front: AAA is a trap for marketing sites, and AA is already more generous than most teams realise. Chase AAA across a whole product and you end up with a palette of six near-blacks, three near-whites, and a brand color that got demoted to decoration. Hit AA cleanly, document the exceptions, and move on.

Everything else here is mechanics — the formulas, the thresholds nobody memorises, the pattern of mistakes designers keep making, and the tools that actually compute contrast correctly rather than eyeballing it.

What WCAG contrast actually measures

The number you see in audits is a ratio between the relative luminance of two colors — foreground against background — on a scale from 1 (identical) to 21 (pure black on pure white). It is not perceptual lightness, it is not L\* from Lab, and it does not account for hue. Blue on black at 4.6:1 passes AA even though it is harder to read than orange on black at 4.5:1, because the formula ignores the fact that human cone sensitivity drops off at the blue end.

The thresholds:

| Rule | AA | AAA | |---|---|---| | Normal text (<18pt / <14pt bold) | 4.5:1 | 7:1 | | Large text (≥18pt / ≥14pt bold) | 3:1 | 4.5:1 | | Non-text UI (icons, borders, focus rings) | 3:1 | — | | Disabled controls | exempt | exempt |

AAA on body text (7:1) means your lightest acceptable grey on white is around #595959. There is no AAA requirement for non-text. There is no WCAG rule that forces you to disable branding on hover states. All of those are team conventions pretending to be standards.

The formula, in one block

``js // sRGB → linear const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 const luminance = ([r, g, b]) => 0.2126 lin(r) + 0.7152 lin(g) + 0.0722 * lin(b) const contrast = (fg, bg) => { const [L1, L2] = [luminance(fg), luminance(bg)].sort((a, b) => b - a) return (L1 + 0.05) / (L2 + 0.05) } contrast([255, 255, 255], [59, 130, 246]) // 3.68 — Tailwind blue-500 on white, FAILS AA for body ``

Keep that function on a sticky note. Half the "my design passes" claims collapse against a two-line implementation.

The failures auditors find in week one

In order of how often they appear in real codebases:

1. Placeholder text at 40% opacity. Looks like 2.4:1. Nobody checks placeholders. 2. Brand blue (#3B82F6, #2563EB, #4F46E5) used for body links on white. They sit in the 3.1–4.4 range. All fail AA for normal text. 3. Grey-500 secondary text. #6B7280 on white = 4.69:1. Passes. #9CA3AF on white = 2.84:1. Fails. Teams swap them casually. 4. Disabled buttons that look exactly like active buttons at reduced opacity. WCAG exempts them, but your QA team won't know which state they're looking at. 5. Focus rings at 1px, light blue, on white backgrounds. 3:1 against the background, 1.4:1 against adjacent surface. Fails SC 1.4.11. 6. Dark mode greys inverted blindly. A palette that works at 4.5:1 on white will usually show 3.1–3.8:1 on #0F172A.

A sanity run with the Accessible Color Pair Generator catches most of these — it only emits pairs that already clear the ratio, so you can shop for secondary text or caption colors without the trial-and-error loop. For auditing a specific pair you already picked, the Color Contrast Pair Generator tells you the exact ratio and which of (AA normal, AA large, AAA normal, AAA large) it meets.

The "it depends on the background" problem

Real pages don't have one background. They have a hero, a card surface, a muted callout, a dark mode variant, and a modal overlay. Each of those is a different contrast context for the same text color.

The practical workaround: pick three background tokens — base, raised, muted — and ensure every text token clears 4.5:1 against all three. That means your "muted" surface can only be so dark before body text starts failing. Most teams discover their muted grey is too close to text grey on the first real audit.

For a seed palette that respects this constraint, the Accessibility-Safe Palette walks each role (heading, body, accent, muted text) downward in lightness from your chosen hue until it clears AA. The reported ratios are the real WCAG math, not a perceptual approximation.

Dark mode is not inverted light mode

A worked example. Protagonist: Priya, redesigning a dashboard called Lintel that ships both themes.

Light mode body: #1F2937 on #FFFFFF → 14.07:1. Fine. Dark mode body: inverted to #E5E7EB on #111827 → 14.5:1. Also fine. Light mode link: #2563EB on #FFFFFF → 4.57:1. Fine for body. Dark mode link: inverted to #DBEAFE on #111827 → 11.9:1. Reads as a flat near-white. Loses the "this is a link" affordance.

The fix: dark mode gets a separately-tuned link color — #60A5FA on #111827 → 7.19:1. Still passes AAA, now actually readable as a link.

If you need a dark-mode-ready palette out of the gate rather than retrofitting one, the Dark Mode UI Palette Generator returns surface + accent sets that are already tuned for low-light use, so you don't inherit the "too saturated" problem that comes from inverting a light palette hue-for-hue.

Large text, bold text, icons: when the rules relax

AA permits 3:1 for anything that is 18pt (24px) regular or 14pt (18.66px) bold or larger. This matters more than people use it. A page heading at #64748B on white (4.2:1) fails for body but passes for headings. Dashboards full of 32px numbers can use softer greys without breaking rules.

Non-text UI (SC 1.4.11) requires 3:1 against adjacent colors for anything that conveys information — focus rings, form field borders when they distinguish states, chart lines, icon-only buttons. A hairline divider between two cards does not need to pass 3:1. A chart gridline that users read values off of does.

The WCAG Contrast Pair Generator lets you specify which threshold you need (3:1 or 4.5:1), so you can generate large-heading candidates and body candidates without manually filtering.

Color blindness is a separate check

Contrast ratios do not cover color blindness. A red-green pair at 4.6:1 passes AA and is invisible to deuteranopia. The common failure modes:

  • Red "required" labels next to green "ok" labels — identical to deuteranopes.
  • Red / green status dots with no icon or shape backup — same problem.
  • Pie charts with 5+ adjacent hues indistinguishable at the ~7% of the audience with any form of CVD.

The fix is never "make the red redder." It is add a shape, a label, an icon, or a pattern. A quick sanity check: generate pairings with the Color Blindness Safe Palette Generator and compare to what you had. If three of your five chart colors collapse to the same mid-grey under deuteranopia simulation, that's your rework.

The "I only have hex, give me ratios" workflow

Say you inherit a brand with 11 defined colors and no accessibility work has been done. The practical loop:

1. List every text-on-background pair the UI actually uses (not every mathematical pair — just the ones that exist in components). 2. Compute each ratio. Flag anything below 4.5:1 for normal text, 3:1 for large / icons. 3. For each failure, do not touch the branded color. Darken (or lighten in dark mode) the text that sits on top. 4. If the branded color IS the text (links, button labels), split it: brand/display keeps the designer-approved hue for large contexts; brand/text is a darker tuning used anywhere font-size < 18pt.

This is the only part of accessibility work where "just use variables" solves the problem. One tuned token per role, not one hex per occurrence.

When to care about APCA

APCA (Advanced Perceptual Contrast Algorithm) is the successor WCAG 3 is pointing toward. It accounts for font weight, size, and the hue sensitivity gap that plain WCAG ignores. Its output is not a ratio — it's a signed "Lc" score where ~75 is roughly equivalent to AA body text and ~90 to AAA.

As of October 2026, APCA is not yet normative. Shipping to it instead of WCAG 2.x means your audit tool and your client's audit tool will disagree. The pragmatic rule: pass WCAG 2.x AA (that's the one lawyers cite), and use APCA as a sanity check when WCAG seems to be lying — bright yellow text on white passes neither, bright blue on black "passes" WCAG and reads terribly.

Three mistakes smart teams still make

1. Testing in Chrome DevTools and calling it done. DevTools picks the computed color of the element — but if the parent has opacity: 0.9, DevTools reports the base color, not the composited one. Composite in your head or screenshot and sample.

2. Treating the a11y linter as the audit. Linters catch what they can parse. They miss SVG icons with hardcoded fill attributes, chart library defaults, email templates, and anything inside an iframe.

3. Running audits only on the design system. Design system components may pass. The marketing page that pulled a one-off callout from a Figma file last Thursday will not. Audit real URLs, not Storybook.

A minimum palette that passes

For a project with no palette, this is a working floor. Every text/background pair below passes AA.

```css :root { --bg: #FFFFFF; --bg-raised: #F8FAFC; --bg-muted: #F1F5F9;

--text: #0F172A; / 19.3:1 on --bg / --text-secondary: #475569; / 7.37:1 on --bg / --text-tertiary: #64748B; / 4.72:1 on --bg — body-OK, borderline on --bg-muted /

--accent: #2563EB; / 4.57:1 on --bg — body-OK as link / --accent-hover: #1D4ED8;/ 6.4:1 / --border: #CBD5E1; / 1.65:1 — decorative only / --border-strong: #94A3B8; / 3.14:1 — passes 1.4.11 / } ```

Those tokens are deliberately boring. Boring passes audits. Interesting palettes get rejected and reworked. If you want to shop for a more opinionated base, the Brand Color Palette Generator spits out full brand sets you can then re-tune for contrast rather than designing from zero.

What to automate, what to inspect by hand

Automate: static-token contrast tests in CI. Every color pair you commit should have a known ratio against every surface it can appear on. A 20-line script that reads your tokens and asserts contrast(fg, bg) >= 4.5 catches 90% of regressions.

Inspect by hand: text that composes over images (hero overlays), text inside charts, form error states, placeholder color, disabled states, every focus ring, and the entire print stylesheet if you have one. These are the places automated tools either lie or ignore.

The thing most teams get wrong is treating accessibility as a review gate. It should be a token system. If the only way to produce an inaccessible component is to invent a new hex code outside the token file, nobody will do that, and the audit becomes a formality rather than a renegotiation.