---
title: "WCAG 2.2 Colour Contrast: A Developer's Guide"
description: "WCAG 2.2 contrast for developers: the 4.5:1 and 3:1 rules, large text, non-text and focus contrast, the luminance maths, and 17 real colour pairs tested."
url: https://frontendhelpers.com/wcag-colour-contrast-guide
updated: 2026-10-12
---

# 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](https://frontendhelpers.com/about) · 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](https://www.w3.org/TR/WCAG22/), 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](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) 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](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) 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](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html), 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:

```css
: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](https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) 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`:

```javascript
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](https://frontendhelpers.com/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 background | Typical use | Ratio | AA text 4.5:1 | AA large & 1.4.11 3:1 | AAA text 7:1 | AAA large 4.5:1 |
| --- | --- | --- | --- | --- | --- | --- |
| `#767676` on `#ffffff` | Secondary text | 4.542 | Pass | Pass | Fail | Pass |
| `#777777` on `#ffffff` | Secondary text | 4.478 | Fail | Pass | Fail | Fail |
| `#959595` on `#ffffff` | Muted text | 2.995 | Fail | Fail | Fail | Fail |
| `#aaaaaa` on `#ffffff` | Muted text | 2.323 | Fail | Fail | Fail | Fail |
| `#3b82f6` blue-500 on `#ffffff` | Links, buttons | 3.678 | Fail | Pass | Fail | Fail |
| `#2563eb` blue-600 on `#ffffff` | Links, focus ring | 5.169 | Pass | Pass | Fail | Pass |
| `#ffffff` on `#f97316` orange-500 | Brand button label | 2.803 | Fail | Fail | Fail | Fail |
| `#ffffff` on `#ea580c` orange-600 | Brand button label | 3.560 | Fail | Pass | Fail | Fail |
| `#ffffff` on `#c2410c` orange-700 | Brand button label | 5.178 | Pass | Pass | Fail | Pass |
| `#9ca3af` gray-400 on `#ffffff` | Placeholder | 2.539 | Fail | Fail | Fail | Fail |
| `#6b7280` gray-500 on `#ffffff` | Placeholder | 4.834 | Pass | Pass | Fail | Pass |
| `#d1d5db` gray-300 on `#ffffff` | Input border | 1.474 | n/a | Fail | n/a | n/a |
| `#ef4444` red-500 on `#ffffff` | Error text | 3.763 | Fail | Pass | Fail | Fail |
| `#dc2626` red-600 on `#ffffff` | Error text | 4.829 | Pass | Pass | Fail | Pass |
| `#2563eb` blue-600 on `#0f172a` slate-900 | Dark-mode link, focus ring | 3.454 | Fail | Pass | Fail | Fail |
| `#60a5fa` blue-400 on `#0f172a` slate-900 | Dark-mode link, focus ring | 7.022 | Pass | Pass | Pass | Pass |
| `#e5e7eb` gray-200 on `#111827` gray-900 | Dark-mode body text | 14.328 | Pass | Pass | Pass | Pass |

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](https://frontendhelpers.com/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:

```javascript
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](https://frontendhelpers.com/color-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](https://www.w3.org/TR/wcag-3.0/), 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](https://frontendhelpers.com/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.

## Keep going

- [Contrast Checker](https://frontendhelpers.com/contrast-checker): Test any colour pair against the AA and AAA thresholds.
 
- [Colour Palette Generator](https://frontendhelpers.com/color-palette-generator): Build tints and shades, then check each text pair.
 
- [Accessible website guide](https://frontendhelpers.com/accessible-website-guide): The practical accessibility fixes beyond colour.
