Dev.to WebDev 🛠 Dev 👁 1 📖 4 min read

WCAG Color Contrast Explained: AA vs AAA, Large Text, and the Gray That Fails

I used to treat contrast as a box to tick at the end of a project. Then an audit flagged half our secondary text and most of our buttons, and I learned how close "looks fine to me" is to "fails WCAG." This is what I wis

I used to treat contrast as a box to tick at the end of a project. Then an audit flagged half our secondary text and most of our buttons, and I learned how close "looks fine to me" is to "fails WCAG."

This is what I wish I'd known earlier. The thresholds, the formula, and a small JS contrastRatio() function are in a gist: WCAG Color Contrast Ratio Cheat Sheet. Every number in this post comes from that script.

What the ratio actually means

A contrast ratio compares how bright two colors are, but not the way you'd guess. Each color gets turned into a "relative luminance" between 0 (black) and 1 (white). The formula weights green much more than blue, because our eyes are far more sensitive to green. Then you take (lighter + 0.05) / (darker + 0.05).

The result runs from 1:1 (same color) to 21:1 (black on white). So 4.5:1 doesn't mean "4.5 times different" in any intuitive sense. It's just the scale WCAG uses.

Because of that weighting, two colors that look equally "strong" can score very differently. Tailwind's red-600 (#dc2626) behind white text gets 4.82:1, while green-600 (#16a34a) gets only 3.29:1. Green is perceived as much lighter.

AA vs AAA

WCAG 2.x has two levels you'll actually run into:

  • AA is what most laws and contracts point to (ADA cases, EN 301 549 in the EU). Normal text needs 4.5:1.
  • AAA is stricter. Normal text needs 7:1.

I aim for AA everywhere and AAA for long-form body text, like docs, articles, and anything people actually read for a while. Hitting AAA across a whole product is hard and usually not required.

One detail that catches people: the ratio is not rounded. A color at 4.49:1 fails AA. I've seen "4.5" in a design tool that was really 4.47.

Large text gets a discount

Bigger, bolder text is easier to read, so the bar drops: 3:1 for AA and 4.5:1 for AAA. "Large" has a specific meaning: at least 24px regular, or about 18.66px (14pt) bold. A 20px medium-weight heading does not count, even though it feels big.

UI components count too

Since WCAG 2.1 there's a 3:1 rule for non-text things you need to see in order to use the interface: input borders, icons that carry meaning, focus indicators, chart lines, checkbox outlines. This is the one that bit me hardest.

The classic failure is a light gray input border. Tailwind's gray-300 (#d1d5db) on white is 1.47:1. It looks clean, but for a lot of people the field basically isn't there. You don't need a dark border, though. A border that's clearly visible, or a filled background with enough contrast, does the job.

The gray pitfalls

Gray text on white is where most contrast bugs live. These are the ones I check now:

  • #767676 on white is 4.54:1. That's the lightest pure gray that passes AA for normal text. Burn it into your memory.
  • #777777, one step lighter, is 4.47:1 and fails. Same goes for any "close enough" gray.
  • #999999, a very common "muted" color, is 2.84:1. It fails even the large-text bar.
  • Tailwind's gray-500 (#6b7280) is 4.83:1 on white, so it's fine. gray-400 (#9ca3af) is 2.53:1, so it isn't.

Backgrounds matter as much as text. gray-500 passes on white but drops to 4.39:1 on gray-100 (#f3f4f6) and fails. #767676 on #f5f5f5 is 4.16:1. So a gray that passes on your page background can fail inside a light gray card or table row. Check text against the surface it actually sits on.

Brand colors on white

The second big source of failures is the brand color, usually a bright blue or green. Designers want white text on it and it doesn't quite get there:

  • Bootstrap 4's #007bff with white text: 3.97:1. Fine for big buttons, fails for normal text.
  • Bootstrap 5 switched to #0d6efd: 4.50:1. It passes AA, but just barely.
  • Tailwind blue-500 (#3b82f6): 3.67:1. blue-600 (#2563eb): 5.16:1.
  • Tailwind green-500 (#22c55e): 2.27:1. You need green-700 (#15803d, 5.01:1) for white text.
  • Amber/yellow (#f59e0b) with white text is 2.14:1. With black text it's 9.77:1.

My fix is usually to keep the bright brand color for big surfaces and illustrations, and add a darker shade of the same hue for text and buttons. On yellows and oranges, I switch to dark text. It almost always looks better anyway.

How I check now

A simple trick: run a small script in CI that computes ratios for your design tokens (the gist function is enough for this) and fail the build if any text/background pair you actually use drops below the threshold. That catches regressions when someone tweaks a color.

For quick one-off checks while designing, I paste two colors into this colour contrast checker. It runs in the browser and shows AA/AAA pass or fail right away.

Also check the states people forget: hover, focus rings, placeholders, and text over images or semi-transparent overlays. Those are where audits find the leftovers.

Contrast isn't the whole accessibility story, but it's measurable and cheap to fix. Fixing it early beats finding out in an audit.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.