Skip to main content
CSS & Design

WCAG 2.2 Color Contrast: Hitting AA Without Guessing

A practical guide to WCAG 2.2 contrast ratios - the AA and AAA thresholds, the luminance math, why the formula is flawed, and how APCA fixes it.

By · · 11 min read

Most contrast failures I see in code review are not edge cases. They are gray placeholder text on white, a #999 on #fff that someone eyeballed and shipped. The fix is almost always trivial once you know the number you are aiming for. This is the number, where it comes from, and why the official math is partly wrong.

What a contrast ratio actually measures

A contrast ratio is a single number describing how different two colors are in brightness, not hue. It runs from 1:1 (identical, invisible) up to 21:1 (pure black on pure white). Everything you design lands somewhere on that line.

The key word is brightness, specifically relative luminance. Two colors can look wildly different to your eye - a saturated red and a saturated blue - and still sit at nearly the same luminance, which means low contrast and unreadable text. Hue does not save you. Luminance is what the formula cares about, and it is what the eye uses to resolve fine detail like letterforms.

Relative luminance is a weighted sum of the red, green, and blue channels after they have been converted from the gamma-encoded sRGB values your CSS uses into linear light. Green is weighted heavily (about 0.72) because the human eye is most sensitive to it; blue barely counts (0.07). That is why pure blue text on black is so hard to read even though blue feels “bright” - it carries almost no luminance.

The WCAG 2.2 thresholds

Here is the table worth bookmarking. These are the success criteria from WCAG 2.2, which kept the contrast requirements unchanged from 2.1.

ElementAAAAASC
Normal text4.5:17:11.4.3 / 1.4.6
Large text3:14.5:11.4.3 / 1.4.6
UI components and graphical objects3:1n/a1.4.11
Focus indicators3:1n/a1.4.11
Logos, disabled controls, decorativeexemptexempt-

A few things people get wrong reading this:

  • AAA is not “better AA you should always do.” For body text 7:1 is genuinely hard to hit with a brand palette, and it is not the legal baseline anywhere I have shipped. AA is the target. Reach for AAA on long-form reading surfaces if you can.
  • The 3:1 row for UI components is a separate criterion (1.4.11) and a lot of teams forget it exists. More on that below.
  • “Large text” has a precise definition, and it is not “looks big.”

What counts as large text

Large text is 18 point or larger, or 14 point bold or larger. In CSS pixels that works out to:

  • 24px and up for regular weight
  • 18.66px and up for bold (700)

That 18.66 is the real number, not 18 or 19. It comes from 14pt at the 96dpi assumption browsers use (14 * 96 / 72 = 18.66). If your bold heading is 18px, it is not large text and needs 4.5:1. One pixel matters here, and it has bitten me in audits.

Non-text contrast: the rule everyone forgets

SC 1.4.11 is the one that catches teams off guard. It says any non-text element you need to perceive to use the interface must hit 3:1 against its adjacent colors. That covers:

  • The border or fill of a button, input, or checkbox - whatever visually defines its boundary
  • Icons that carry meaning (a trash icon, a status dot, a chart line)
  • Focus indicators, the ring or outline that shows keyboard position
  • Toggle and slider states

The classic failure: a search input with a #e0e0e0 1px border on a white page. That border is about 1.6:1. A sighted low-vision user cannot tell where the field is. Bumping it to a #767676-class gray gets you over 3:1 and the form suddenly has structure.

Focus rings are the other big one. A faint outline that matches your brand blue against a blue button can drop under 3:1 and effectively disappear for keyboard users. Give the focus indicator its own contrast budget, separate from the button’s resting state.

How the ratio is computed

The math is short. You take each color, convert sRGB to linear light, compute luminance, then plug both luminances into the ratio formula. Here it is in JS:

// channel: 0-255 sRGB value -> linear light 0-1
function linearize(c) {
  c = c / 255;
  return c <= 0.04045
    ? c / 12.92
    : Math.pow((c + 0.055) / 1.055, 2.4);
}

function relativeLuminance({ r, g, b }) {
  const R = linearize(r);
  const G = linearize(g);
  const B = linearize(b);
  // Rec. 709 / sRGB luminance weights
  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

function contrastRatio(fg, bg) {
  const L1 = relativeLuminance(fg);
  const L2 = relativeLuminance(bg);
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

Two details that explain a lot of the formula’s behavior:

The 2.4 exponent and the 0.04045 split are the sRGB transfer function. Below that threshold the curve is linear (the dark end), above it it is a power curve with gamma 2.4. This is why small numeric changes in dark colors swing luminance more than the same change in bright colors.

The 0.05 offset added to both luminances is the flare term. It models ambient light bouncing off a screen, so pure black never reaches zero luminance. Without it, black-on-anything would compute as a much higher ratio. That single constant is also where most of the formula’s known inaccuracy lives.

Why the formula is genuinely flawed

The 2.x contrast formula is from the late 1990s and it was never meant to model perception across the full range of screens we use now. Two failure modes show up constantly.

It overstates dark-mode contrast. Light text on a dark background routinely computes a high ratio while reading worse than the number suggests. The 0.05 flare offset dominates the dark end, so two dark colors that look nearly identical can report a passing ratio. I have seen #bbb on #222 pass 4.5:1 in a checker and still feel muddy on a real laptop screen.

It understates some mid-tones. Certain gray-on-gray and saturated mid-luminance pairs fail the formula while being perfectly legible, which pushes designers toward harsher palettes than they need. The formula treats contrast as symmetric (swapping fg and bg gives the same number), but human perception of text is not symmetric - dark text on light reads differently than the inverse at the same computed ratio.

So the number is a useful guardrail, not ground truth. Passing 4.5:1 is a good sign. It is not a guarantee, and a borderline pass in dark mode deserves a real human look.

APCA and the WCAG 3 future

The replacement is APCA, the Accessible Perceptual Contrast Algorithm, which is the candidate contrast method for WCAG 3 (still a working draft, years from being a standard). It is built differently in ways that matter:

  • It is perceptual and based on lightness difference, tuned against actual readability research rather than a 1990s luminance ratio.
  • It is asymmetric. Polarity matters, so dark-on-light and light-on-dark get scored on their own terms. That directly fixes the dark-mode overstatement.
  • It factors in font size and weight as part of the score, instead of the blunt two-tier “normal vs large” split. Thin 14px text needs more contrast than bold 24px, and APCA says so.
  • Its output is an Lc (lightness contrast) value roughly 0-106, not a ratio. A common rough mapping: Lc 60 for body text, Lc 75 for smaller or thin text.

I do not ship to APCA as a compliance target yet, because WCAG 2.2 AA is what audits, contracts, and lawsuits reference. But I use an APCA reading as a tie-breaker. When a color pair barely scrapes 4.5:1 and APCA says it is weak, I trust APCA and adjust.

A workflow that does not involve guessing

Here is the loop I actually use when building a palette.

  1. Pick the colors for intent first - brand, hierarchy, state - without worrying about contrast. Use a tool like the Color Picker to sample exact values from a mockup or an existing screen instead of approximating from memory.
  2. Build the full set as a system, not pair by pair. The Color Palette Generator is useful for laying out a ramp of tints and shades so you have legal options at each step before you start placing text.
  3. Check every text-on-surface pair against the table above. Most checkers report the ratio directly.
  4. When a pair fails, adjust lightness, not hue. This is the single most useful rule. Brand identity lives mostly in hue and saturation; contrast lives in lightness. Convert the color to HSL with the Color Converter , then drop or raise the L value until the ratio passes. The color still reads as “your blue,” just darker or lighter.
  5. Verify the result the way users see it, including a Color Blindness Simulator pass.

Hover, disabled, and focus states

Resting state is the easy part. The states are where things slip.

  • Hover: do not let a hover background drop your text under threshold. If your button text passes at rest and the hover state darkens or lightens the fill, re-check the text at the hover color. They are different pairs.
  • Disabled: disabled controls are explicitly exempt from the contrast rules, so you can gray them out below 4.5:1. But exempt does not mean “make it invisible.” Keep disabled state distinguishable enough that users know the control exists; just do not let it be the only signal of disabled-ness, pair it with a cursor or label cue.
  • Focus: give the focus indicator its own 3:1 against the page, and make sure it is not the same color as a nearby active element. A two-tone outline (a light inner ring plus a dark outer ring) is a cheap trick that passes 3:1 against both light and dark surroundings.
/* focus ring that holds 3:1 on most backgrounds */
:focus-visible {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px #ffffff; /* light halo separates ring from element */
}

The color blindness angle

Contrast ratio and color blindness are related but not the same problem. The ratio formula already approximates this for you - because it is luminance based, a pair that passes 4.5:1 is usually distinguishable regardless of color vision type. Luminance is the channel most color-vision deficiencies preserve.

The trap is using color as the only carrier of information. Red error text and green success text can both pass contrast against white and be indistinguishable from each other for someone with deuteranopia. The fix is not more contrast, it is redundant encoding: an icon, a label, a shape, or a position difference alongside the color. Run your error and status states through a simulator and confirm they are still telling different stories when hue is stripped out.

Roughly 8% of men have some form of red-green color vision deficiency. That is not a fringe case you can defer.

Summary

Contrast is a luminance comparison, not a color one, and the targets are fixed: 4.5:1 for normal text, 3:1 for large text and any meaningful non-text element under SC 1.4.11, 7:1 if you are chasing AAA. Large text means 24px regular or 18.66px bold, and that one-pixel boundary is real. The WCAG 2.x formula linearizes sRGB, weights the channels toward green, and adds a 0.05 flare offset - and that aging math overstates dark-mode contrast and trips on some mid-tones, which is exactly what APCA and the WCAG 3 draft are built to fix. Until then, build the palette as a system, fix failures by changing lightness instead of hue, give focus rings their own contrast budget, and never let color be the only thing carrying meaning.

Tools mentioned in this article

  • Color Picker - Pick colors visually and get HEX, RGB, and HSL values.
  • Color Converter - Convert colors between HEX, RGB, HSL and CMYK formats.
  • Color Blindness Simulator - Simulate how colors appear with protanopia, deuteranopia, tritanopia, and achromatopsia using color transformation matrices.
  • Color Palette Generator - Generate harmonious color palettes, Tailwind-style 50-900 scales and design-system tokens from any base hex color. Also a hex color palette generator (Farbpalette Generator auf Deutsch).