WCAG AA contrast in a real design system

4.5:1 and 3:1 are easy to quote. Most failures hide in placeholders and dark mode, and the fix is to ship colour pairs that already pass.

The three numbers

WCAG 2 level AA asks for three contrast minimums. AA is the level most accessibility laws and procurement rules point at, so it's the one to design for.

WhatMinimumSuccess criterion
Normal text4.5:11.4.3 Contrast (Minimum)
Large text3:11.4.3 Contrast (Minimum)
UI components and meaningful graphics3:11.4.11 Non-text Contrast

The ratio runs from 1:1 (the same colour twice) to 21:1 (black on white). Level AAA raises text to 7:1 and large text to 4.5:1, but AA is the usual target.

"Large" has a precise definition: at least 18 point, or 14 point bold. With CSS's 96 pixels to the inch, that's 24px regular or 18.67px bold. A 20px medium-weight heading is not large text. Neither is a 16px bold label. If a pair only passes at 3:1, the type using it has to actually be that big.

Non-text contrast covers what you need to see to find a control and read its state: an input's outline when the outline is the only thing showing where the field is, a checkbox border, the difference between a toggle's on and off, a focus ring, the lines of a chart that carry data. It's measured against whatever sits next to it.

How the ratio is calculated

Contrast is the ratio of the two colours' relative luminance, each plus 0.05. Luminance undoes the sRGB gamma curve, then weights green heavily and blue barely:

function luminance(hex) {
  const [r, g, b] = [0, 2, 4].map((i) => {
    const c = parseInt(hex.slice(i + 1, i + 3), 16) / 255;
    return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

function contrast(a, b) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

contrast("#767676", "#FFFFFF"); // 4.542...

Those weights are why yellow and blue behave so differently. Pure yellow #FFFF00 is 1.07:1 against white. Pure blue #0000FF is 8.59:1.

One rule people get wrong: don't round. The WCAG understanding document says it plainly: 4.499:1 does not meet 4.5:1. #777777 on white is 4.48:1, and a checker that shows one decimal will print 4.5 and wave it through. It fails. #767676, at 4.54:1, is the lightest neutral grey that passes on white.

Real pairs, measured

Every ratio below comes from the function above. Most of these hexes are from Tailwind v3's default palette, because you've seen them in a hundred products.

ForegroundBackgroundRatioNormal textLarge text and UI
#111827#FFFFFF17.74:1PassPass
#6B7280#FFFFFF4.83:1PassPass
#6B7280#F3F4F64.39:1FailPass
#777777#FFFFFF4.48:1FailPass
#9CA3AF#FFFFFF2.54:1FailFail
#FFFFFF#3B82F63.68:1FailPass
#FFFFFF#2563EB5.17:1PassPass
#FFFFFF#EF44443.76:1FailPass
#FFFFFF#DC26264.83:1PassPass
#FFFFFF#22C55E2.28:1FailFail
#FFFFFF#15803D5.02:1PassPass
#FFFFFF#F59E0B2.15:1FailFail
#111827#F59E0B8.26:1PassPass
#E5E7EB#FFFFFF1.24:1FailFail
#2563EB#1118273.43:1FailPass
#60A5FA#1118276.98:1PassPass
#6B7280#1118273.67:1FailPass
#9CA3AF#1118276.99:1PassPass

White text fails on the 500 blue and red and passes on the 600s. Green is worse: white on the 500 fails even as large text, and you have to go to the 700 before body text passes. Amber needs dark text rather than a darker amber: #111827 on it clears 8:1. And a grey that passes on white (#6B7280, 4.83:1) fails on a light grey panel (4.39:1), which is exactly where muted text tends to live.

Where real systems break

Placeholder text

Placeholders are text, so 1.4.3 applies to them. The classic #9CA3AF placeholder on white is 2.54:1. Darken it to a passing grey, and stop putting anything important in placeholders anyway, since they disappear the moment someone starts typing.

Disabled states

Inactive controls are exempt from both 1.4.3 and 1.4.11, but a disabled button still needs to read as a button, and its label still needs to be legible enough for someone to work out why they can't press it. Don't drop it to 1.5:1 because the spec lets you, and don't let "disabled" be the only explanation: say what's missing.

Text on brand fills

Brand colours get picked in a meeting, not in a contrast checker. Bright blues, friendly greens and anything orange tend to fail with white text, which is usually what the brand guidelines ask for. The options, from best to worst: use a darker step of the brand for buttons and keep the bright one for illustration and display type, switch the label to dark text, or make the text large enough to qualify for 3:1, which rarely suits a button.

Dark mode

Dark themes fail differently. The light theme's primary, #2563EB, gives white text 5.17:1. Used as link text on a #111827 surface, the same blue gets 3.43:1. Reuse the step and your links pass in light mode and fail in dark. The muted grey fails too, at 3.67:1. Dark mode needs its own mapping, usually one or two steps lighter for text and accents. Dark mode is a second theme goes through it.

The opposite trap is maxing out. Pure white on pure black is 21:1, and most dark themes deliberately back off from it. #F9FAFB on #111827 is still 16.98:1.

Borders and focus rings

#E5E7EB on white measures 1.24:1. That's fine for a decorative divider and a failure if it's the only outline on a text input. Focus rings are UI too: 3:1 against whatever they sit on, in both themes.

A word on APCA

APCA, the Accessible Perceptual Contrast Algorithm, is the contrast method most discussed for WCAG 3. It fixes real problems in the WCAG 2 formula. It's polarity-aware, so dark text on light and light text on dark score differently, and it accounts for font size and weight instead of a single large-or-not switch. It also disagrees with WCAG 2 on some pairs, most often mid-tones and dark themes.

Don't switch to it yet. WCAG 3 is still a draft, its contrast method isn't settled, and no law or audit you'll face today references APCA. Design to WCAG 2 AA, because that's what you'll be tested against. Use APCA as a second opinion when a pair passes on paper but looks weak, which happens most in dark mode.

Build contrast into the tokens

Checking pairs one at a time doesn't scale past the first sprint. Make the system hand people pairs that already pass.

  • Pair every fill with an on- token. primary with on-primary, danger with on-danger. Components use the pair and never hard-code white.
  • Tie text tokens to the surfaces they're allowed on. Test text-muted against the darkest surface it can land on, not just the page background.
  • Store the threshold with the pair. A display-size pair and a body-text pair shouldn't be judged by the same bar.
  • Test every theme in CI. A short script that resolves each declared pair in light and dark and fails the build under its threshold catches regressions before a user does.
{
  "color": {
    "primary": { "$type": "color", "$value": "{blue.600}" },
    "on-primary": {
      "$type": "color",
      "$value": "{neutral.0}",
      "$description": "Text and icons on color.primary. Must reach 4.5:1 in every theme."
    }
  }
}

The on- pattern and the per-theme mapping behind it are covered in primitive vs semantic colour tokens.

[!NOTE] In Flash, text and fill pairs are checked against AA (4.5:1 for body text, 3:1 for large text and UI fills) in both themes. In the editor, open Ask Flash with {{⌘K}} and try "Check my text contrast for accessibility". Export's Copy code can also derive a High contrast variant.

Try this today: run your ten most-used text and background pairs through the function above, in both themes. Odds are you'll find a placeholder and a dark-mode link to fix.