WCAG 2.2 colour contrast, explained for developers

The ratios, the exact meaning of large text, non-text and focus contrast, the maths behind the number, and real colour pairs computed rather than guessed.

Guide · By AurumCodeWorks · 9 min read · Updated 12 October 2026

What WCAG 2.2 actually requires

WCAG 2.2 is the current W3C Recommendation, and for colour contrast it changed nothing: success criteria 1.4.3, 1.4.6 and 1.4.11 carry the same wording and the same numbers as in WCAG 2.1. If your palette passed 2.1, it passes 2.2. What 2.2 did add is a new AAA criterion for focus indicators, covered below. The normative text is in the WCAG 2.2 specification, and three criteria do the work:

  • 1.4.3 Contrast (Minimum), level AA: text and images of text need at least 4.5:1 against their background, or 3:1 for large text.
  • 1.4.6 Contrast (Enhanced), level AAA: 7:1 for normal text, 4.5:1 for large text.
  • 1.4.11 Non-text Contrast, level AA: 3:1 for the parts of interface components and graphics that people need to see.

Most organisations target AA, so 1.4.3 and 1.4.11 are the two you will be audited against. The text criteria exempt text in inactive (disabled) components, pure decoration, text nobody can see, and logotypes. The thresholds are hard limits: the Understanding document for 1.4.3 says computed values must not be rounded, so 4.499:1 fails 4.5:1. A checker that displays two decimal places can show you "4.50" for a colour that fails.

The exact definition of "large text"

WCAG defines large-scale text as at least 18 point, or 14 point bold. CSS fixes 1pt at 1.333px, so that is 24px regular or about 18.66px bold. (The Understanding document rounds the second figure to 18.5px.)

Treat those pixel values as approximate, for two reasons. First, point size is a nominal measure: two fonts at 24px can have very different x-heights and stroke widths, and WCAG notes that fonts with thin strokes or unusual letterforms are harder to read at low contrast. A hairline display face at 24px is not large in any useful sense. Second, the definition measures text as you deliver it, not after a visitor zooms. The safe rule is to apply the 3:1 allowance only to text that is at least 24px, or at least 19px in a genuinely bold weight (700 or heavier), in a font of ordinary proportions. Everything else gets 4.5:1.

Non-text contrast: what 1.4.11 covers, and what it doesn't

SC 1.4.11 requires 3:1 against adjacent colours for any visual information needed to identify a control or its state, and for the parts of graphics needed to understand the content. In practice that means:

  • The border of a text input, when the border is the only thing showing where to type.
  • State indicators: the tick in a checkbox, the knob position on a toggle, the underline on a selected tab.
  • Focus indicators, measured against the colours next to them.
  • Icons that carry meaning without a text label, and chart lines, bars or segments you need to tell apart.

The Understanding document for 1.4.11 is just as clear about what is out of scope. Disabled and other inactive components are exempt. So are logos, decorative graphics, and controls whose appearance comes from the browser and which you haven't restyled. Hover styling is treated as supplementary, because the pointer already shows where you are. A button with a clear text label does not need a contrasting outline, because the text identifies it. And 1.4.11 does not compare a control's focused and unfocused states with each other; that is the job of 2.4.13.

Focus: 2.4.7 Focus Visible versus 2.4.13 Focus Appearance

2.4.7 Focus Visible (AA) only says keyboard focus must be visible. Combined with 1.4.11, your indicator must also reach 3:1 against its adjacent colours. 2.4.13 Focus Appearance is new in WCAG 2.2 at level AAA, and it is measurable. Per the Understanding document for 2.4.13, an area of the indicator must be at least as large as a 2 CSS pixel thick perimeter of the unfocused component, and those pixels must change by at least 3:1 between the unfocused and focused states. Untouched browser default focus styles are exempt.

A solid outline with an offset meets both parts comfortably, as long as the outline colour contrasts 3:1 with the page background it is drawn over:

:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

The maths: relative luminance and the contrast ratio

The contrast ratio is (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter colour and L2 that of the darker. It runs from 1:1 (identical colours) to 21:1 (black on white). To get relative luminance from an sRGB colour:

  1. Divide each 8-bit channel by 255 to get a value from 0 to 1.
  2. Linearise it: if the value is 0.04045 or less, divide by 12.92; otherwise compute ((c + 0.055) / 1.055)2.4.
  3. Weight the channels: L = 0.2126R + 0.7152G + 0.0722B. Green dominates because the eye is most sensitive to it.

Older articles, and WCAG itself before May 2021, give the threshold as 0.03928. The current definition uses 0.04045 and notes the change has no practical effect. For hex colours it has none at all: no 8-bit channel value falls between the two thresholds (10/255 is 0.03922 and 11/255 is 0.04314), so both versions produce identical ratios.

Here is a complete implementation that accepts #rgb and #rrggbb:

function contrastRatio(hexA, hexB) {
  const luminance = (hex) => {
    let h = hex.replace('#', '');
    if (h.length === 3) h = [...h].map((c) => c + c).join('');
    if (!/^[0-9a-f]{6}$/i.test(h)) throw new Error(`Not a hex colour: ${hex}`);
    const [r, g, b] = [0, 2, 4].map((i) => {
      const c = parseInt(h.slice(i, i + 2), 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;
  };
  const [lighter, darker] = [luminance(hexA), luminance(hexB)].sort((a, b) => b - a);
  return (lighter + 0.05) / (darker + 0.05);
}

contrastRatio('#767676', '#fff').toFixed(3);    // "4.542"
contrastRatio('#959595', '#ffffff').toFixed(3); // "2.995"

Run in Node, it reproduces every value in the table below. Our contrast checker runs the same calculation in the browser.

Real colour pairs, computed

Every ratio below was calculated with the function above and is shown to three decimal places; pass or fail was decided on the unrounded value. The named colours are hex values from Tailwind CSS v3's default palette, chosen because so many projects start from it.

Foreground on backgroundTypical useRatioAA text 4.5:1AA large & 1.4.11 3:1AAA text 7:1AAA large 4.5:1
#767676 on #ffffffSecondary text4.542PassPassFailPass
#777777 on #ffffffSecondary text4.478FailPassFailFail
#959595 on #ffffffMuted text2.995FailFailFailFail
#aaaaaa on #ffffffMuted text2.323FailFailFailFail
#3b82f6 blue-500 on #ffffffLinks, buttons3.678FailPassFailFail
#2563eb blue-600 on #ffffffLinks, focus ring5.169PassPassFailPass
#ffffff on #f97316 orange-500Brand button label2.803FailFailFailFail
#ffffff on #ea580c orange-600Brand button label3.560FailPassFailFail
#ffffff on #c2410c orange-700Brand button label5.178PassPassFailPass
#9ca3af gray-400 on #ffffffPlaceholder2.539FailFailFailFail
#6b7280 gray-500 on #ffffffPlaceholder4.834PassPassFailPass
#d1d5db gray-300 on #ffffffInput border1.474n/aFailn/an/a
#ef4444 red-500 on #ffffffError text3.763FailPassFailFail
#dc2626 red-600 on #ffffffError text4.829PassPassFailPass
#2563eb blue-600 on #0f172a slate-900Dark-mode link, focus ring3.454FailPassFailFail
#60a5fa blue-400 on #0f172a slate-900Dark-mode link, focus ring7.022PassPassPassPass
#e5e7eb gray-200 on #111827 gray-900Dark-mode body text14.328PassPassPassPass

A few things stand out. #767676 is the lightest neutral grey that passes 4.5:1 on white; one step lighter, #777777, fails. #959595 scores 2.995:1, which a rounding checker shows as 3.00, yet it fails even the large-text threshold. Tailwind's blue-500 is fine for large headings but not for body links. White text on a typical brand orange fails outright until you darken to orange-700. And a blue-600 focus ring that is comfortable on white (5.169:1) drops to 3.454:1 on a dark slate background: still a 1.4.11 pass, but with very little margin.

Text over images, gradients and transparency

Contrast is measured against the background the text is actually rendered on. Over a photograph or gradient, that means the worst point behind the glyphs: the area closest in lightness to the text. Background images that leave text below the threshold are a documented WCAG failure (F83). The dependable fix is a scrim, a semi-opaque overlay between image and text. Check the text against the composited colour of the scrim over the most troublesome part of the image, not against the scrim colour alone.

Transparency needs the same treatment. A colour with an alpha channel has no contrast ratio on its own; you have to composite it over whatever sits behind it and measure the result. Take rgba(0, 0, 0, 0.54), a popular secondary-text colour. Over white it composites to #757575 and scores 4.608:1, a pass. Put the same token on a #f3f4f6 card and it composites to #707071 at 4.495:1, a fail. The same applies to opacity on a parent element, which fades the text along with everything else. And always set foreground and background together: specifying only one leaves you at the mercy of the user's defaults, which WCAG also treats as a failure.

Dark mode pitfalls

A dark theme is a second palette, and every pair in it needs re-checking. Colours don't carry over: blue-600 links pass at 5.169:1 on white but score 3.454:1 on slate-900, a fail for body text, while the lighter blue-400 scores 7.022:1 on the same background. Two more traps:

  • Pure white on pure black gives the maximum 21:1, but many readers, particularly those with astigmatism, find it glaring, with letters appearing to bleed into the background. Off-white on near-black, such as gray-200 on gray-900, still scores 14.328:1 and is far easier on the eye.
  • Shadows stop working. A dark shadow on a dark surface is invisible, so cards and menus need lighter surface colours or borders to separate them. Where a border is the only thing that identifies a control, it needs 3:1.

Our dark mode implementation guide covers structuring the CSS so that each theme's colour pairs live in one place.

Hover, focus, error and placeholder states

Each state is a new colour pair. Text shown on hover or focus is still text, so a button whose hover background lightens must keep its label at 4.5:1. Error messages are text too: red-500 on white manages only 3.763:1, while red-600 passes at 4.829:1. Identify errors in words or with an icon as well, because colour alone fails SC 1.4.1. Placeholder text is covered by 1.4.3, and the common gray-400 placeholder fails at 2.539:1, whereas gray-500 passes at 4.834:1. Disabled controls are exempt, but if you rely on that, make sure the control genuinely is inactive and not merely pale.

Bake contrast into your design tokens

The most reliable way to keep contrast right is to stop choosing colours one at a time. Define semantic tokens in pairs, such as --text-on-surface with --surface and --text-on-accent with --accent, and only ever use a text token on its named background. Then test the pairs automatically:

const pairs = [
  ['--text-on-surface', '#111827', '#ffffff', 4.5],
  ['--text-muted', '#6b7280', '#ffffff', 4.5],
  ['--focus-ring', '#2563eb', '#ffffff', 3],
];
for (const [name, fg, bg, min] of pairs) {
  const ratio = contrastRatio(fg, bg);
  if (ratio < min) throw new Error(`${name}: ${ratio.toFixed(3)} < ${min}`);
}

Run that in CI and anyone who changes a hex value in the token file gets an immediate failure rather than an audit finding six months later. When you need a full scale to pick from, our colour palette generator builds tints and shades from a base colour that you can test in the same way.

APCA and WCAG 3: not a requirement

You will see APCA, the Accessible Perceptual Contrast Algorithm, recommended as a better model. It accounts for things the WCAG 2 ratio ignores, such as font size and weight and whether text is light on dark or dark on light, and it is a reasonable extra check while designing. It is not a conformance requirement. WCAG 3 is still a Working Draft: the latest version, dated 10 September 2026, says it is inappropriate to cite as anything other than work in progress, and its text contrast requirement states that the contrast measure is yet to be determined. APCA was explored in early WCAG 3 drafts, but no algorithm has been adopted. Laws, contracts and procurement rules reference WCAG 2.x, so the 4.5:1 and 3:1 ratios remain the bar you must clear. For the rest of the accessibility picture, see our accessible website guide.

Frequently asked questions

What contrast ratio does WCAG 2.2 AA require?

At least 4.5:1 for normal text and 3:1 for large text (18pt, or 14pt bold, which is about 24px or 18.66px bold). User interface components, focus indicators and meaningful graphics need 3:1 against adjacent colours. These numbers are unchanged from WCAG 2.1.

What is the lightest grey text that passes WCAG AA on white?

#767676, at 4.542:1. One step lighter, #777777, scores 4.478:1 and fails, because WCAG ratios are never rounded up. For large text, #949494 is the lightest neutral grey that passes 3:1; #959595 fails at 2.995:1.

Does placeholder text have to meet contrast requirements?

Yes. Placeholder text is text, so SC 1.4.3 applies and it needs 4.5:1 at normal sizes. The common light-grey placeholder #9ca3af scores only 2.539:1 on white; #6b7280 passes at 4.834:1.

Should I use APCA instead of the WCAG contrast ratio?

Not for conformance. WCAG 3 is still a Working Draft and has not settled on a contrast algorithm, so APCA is not a requirement anywhere. You can use it as an extra design check, but your colours must still pass the WCAG 2.2 ratios.