# 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. - Source: https://flashdsm.com/blog/contrast-wcag-aa-in-practice - Published: 2026-09-11 - Author: Flash DSM Team - Track: Fundamentals - Reading time: 6 min read --- ## 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. | What | Minimum | Success criterion | |---|---|---| | Normal text | 4.5:1 | 1.4.3 Contrast (Minimum) | | Large text | 3:1 | 1.4.3 Contrast (Minimum) | | UI components and meaningful graphics | 3:1 | 1.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: ```js 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](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) 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. | Foreground | Background | Ratio | Normal text | Large text and UI | |---|---|---|---|---| | `#111827` | `#FFFFFF` | 17.74:1 | Pass | Pass | | `#6B7280` | `#FFFFFF` | 4.83:1 | Pass | Pass | | `#6B7280` | `#F3F4F6` | 4.39:1 | Fail | Pass | | `#777777` | `#FFFFFF` | 4.48:1 | Fail | Pass | | `#9CA3AF` | `#FFFFFF` | 2.54:1 | Fail | Fail | | `#FFFFFF` | `#3B82F6` | 3.68:1 | Fail | Pass | | `#FFFFFF` | `#2563EB` | 5.17:1 | Pass | Pass | | `#FFFFFF` | `#EF4444` | 3.76:1 | Fail | Pass | | `#FFFFFF` | `#DC2626` | 4.83:1 | Pass | Pass | | `#FFFFFF` | `#22C55E` | 2.28:1 | Fail | Fail | | `#FFFFFF` | `#15803D` | 5.02:1 | Pass | Pass | | `#FFFFFF` | `#F59E0B` | 2.15:1 | Fail | Fail | | `#111827` | `#F59E0B` | 8.26:1 | Pass | Pass | | `#E5E7EB` | `#FFFFFF` | 1.24:1 | Fail | Fail | | `#2563EB` | `#111827` | 3.43:1 | Fail | Pass | | `#60A5FA` | `#111827` | 6.98:1 | Pass | Pass | | `#6B7280` | `#111827` | 3.67:1 | Fail | Pass | | `#9CA3AF` | `#111827` | 6.99:1 | Pass | Pass | 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](/blog/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. ```json { "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](/blog/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.