WCAG Contrast — Passes Tool, Fails in Sunlight
The design system badge says “AA.” The contrast checker is green. A teammate still cannot read the secondary label on a park bench at noon. WCAG ratios are necessary; they are not a sunlight simulator. Treat 4.5:1 as a floor for body text, not a trophy.
What the numbers mean
Contrast ratio compares relative luminance of foreground and background, from 1:1 (same) to 21:1 (black on white).
| Level | Normal text | Large text |
|---|---|---|
| AA | ≥ 4.5:1 | ≥ 3:1 |
| AAA | ≥ 7:1 | ≥ 4.5:1 |
“Large” is about rendered size and weight (roughly 18pt+ or 14pt+ bold), not the CSS keyword large. If marketing sets font-size: 15px and calls it a hero, it is still normal text for WCAG. Measure what users get after responsive type scales down.
Non-text UI (icons, input borders, focus rings that convey state) generally needs 3:1 against adjacent colors under WCAG 2.1. Decorative flourishes are exempt; meaning-bearing chrome is not.
Why lab passes fail outdoors
- Brightness caps — users turn phones down; your soft gray collapses
- Reflections — polarizing glare eats mid-contrast pairs first
- Anti-aliasing — light text on mid backgrounds gets optically thinner
- Overlays —
rgbascrims and images behind text change the effective background the Figma frame never showed - Color blindness — contrast ≠ hue distinction; do not rely on red/green alone
Always measure the actual pixels behind the glyphs (including images and gradients), not the token sitting in an empty auto-layout frame. Use a color contrast checker on pairs sampled from DevTools after themes apply.
Gray-on-gray and “brand soft”
text #737373 on bg #F5F5F5 → often ~3.x:1 (fails AA normal)
text #525252 on bg #F5F5F5 → closer to 4.5–5:1
Designers love washed chrome. Body copy should not live there. Keep muted colors for borders and placeholders; keep primary reading text darker (or backgrounds lighter). Placeholder text that matches disabled labels trains users to ignore both.
Placeholder, disabled, and link styles
| Element | Frequent mistake | Better approach |
|---|---|---|
| Placeholder | Same gray as disabled label | Darker placeholder or visible label above the field |
| Disabled | Ultra-low contrast “to look disabled” | Opacity and keep text ≥ 3:1 if it must remain readable |
| Links | Color alone | Color + underline or weight so contrast is not the only cue |
| Focus | 1px light-blue ring on white | 3:1+ ring or dual-color focus indicator |
Dark mode is a second audit
Inverting a light theme rarely preserves ratios. Soft gray text on #121212 may pass AA yet feel weak on OLED glare. Check hover, active, and visited states independently — each pair can fail while the resting state passes.
AA vs AAA without audit theater
Ship AA as the floor for product UI. Push AAA where people read for a long time (docs, legal, education). Do not claim AAA site-wide if charts, maps, and disabled states cannot comply — auditors notice theater.
Pair automation (axe, Lighthouse, CI contrast checks on tokens) with two manual checks: dark mode tokens, and a phone screenshot in daylight. Automation will not catch “passes 4.51 but feels illegible.”
A concrete remediation loop
- Pick failing selector from DevTools.
- Sample foreground and true background (eyedrop after overlays).
- Raise text luminance contrast until ≥ 4.5:1 (or ≥ 3:1 if truly large).
- Re-check hover/focus/dark variants — they fail independently.
- Update the design token, not one-off hexes in a single component.
- Add a regression screenshot or token unit test so the soft gray does not return next quarter.
Contrast is a ratio, a viewing condition, and a content decision. Clear the AA bar in tools, then give body text a little headroom so sunlight — and a dim commute — do not undo the audit.