Skip to main content
CSS & Design

WCAG 2.2 Color Contrast: Hitting AA Without Guessing

The AA and AAA thresholds, the luminance maths behind them, the two pairs the formula scores identically and reads differently, and what APCA changes.

By 12 min read
Article title card. A grey dot on a white circle beside a grey dot on a near-black circle, with the line: the formula scores these the same

Most contrast failures are not edge cases. They are grey placeholder text on white: #999 on #fff, which computes to 2.85:1 against a requirement of 4.5, eyeballed and shipped.

The fix is usually trivial once you know the number you are aiming for. Here is the number, where it comes from, and the two places the official maths is 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.21, 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 AA is what regulation and procurement generally reference. Reach for AAA on long-form reading surfaces where 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 x 96 / 72 = 18.66). If a bold heading is 18px it is not large text and needs 4.5:1. Two thirds of a pixel decides which threshold applies, which is exactly the kind of thing an audit finds and a designer does not.

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
Two text inputs on a white page. The first has a light grey border at 1.32 to 1, under the 3 to 1 required by SC 1.4.11. The second uses a darker grey at 4.54 to 1 and the boundary is obvious
Both borders are one pixel wide. The only change is the grey.

The classic failure is a search input with a #e0e0e0 one-pixel border on a white page. That border computes to 1.32:1, less than half of what the criterion asks. A low-vision user cannot tell where the field is. Moving to #767676 gives 4.54:1, comfortably past 3:1, and the form suddenly has structure. Incidentally #767676 is the darkest grey that still passes 4.5:1 on white, which makes it a useful number to remember.

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 is symmetric, and reading is not. Swap foreground and background and the formula returns the same number, because it only ever compares two luminances. Perception does not work that way.

Two colour pairs the WCAG formula scores almost identically: grey 767676 on white at 4.54 to 1, and grey 8a8a8a on dark grey 222222 at 4.61 to 1
Both pass AA for body text. Look at them rather than at the numbers.

#767676 on white computes 4.54:1. #8a8a8a on #222222 computes 4.61:1, marginally better by the formula, and it is visibly harder work. The 0.05 flare offset dominates the dark end, so two dark colours that sit close together can still report a passing ratio.

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 running to plus or minus 106, not a ratio, and the sign carries the polarity: a negative Lc means light text on a dark background2. That sign is the asymmetry, expressed in the number itself.
  • Its published levels run the opposite way from how they are usually quoted. Body text is the demanding case, not the easy one: Lc 90 is the preferred level for body text at 14px regular, Lc 75 the minimum for body text at 18px, and Lc 60 is for non-body text at 24px regular or 16px bold2. Higher Lc means more contrast required, and the smallest, most-read text needs the most.

APCA is not a compliance target yet, because WCAG 2.2 AA is what audits, contracts and litigation reference. It works well as a tie-breaker: when a pair barely scrapes 4.5:1 and APCA scores it weak, the APCA reading is the one describing what a reader will experience.

A workflow that does not involve guessing

A loop that avoids guessing.

  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.

Red-green colour vision deficiency affects roughly 8 percent of men and about 0.4 percent of women of European descent, with prevalence varying substantially by ancestry3. On any audience of reasonable size that is not a 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.

Sources

Every number in this article traces to a source below. Where a claim could not be sourced, it was cut rather than softened.

  1. Primary sourceW3C

    The 4.5 to 1 requirement in SC 1.4.3, the 7 to 1 requirement in SC 1.4.6, the 3 to 1 requirement in SC 1.4.11 for user interface components and graphical objects, and the definition of large-scale text as 18 point, or 14 point bold.

  2. Peer-reviewedMyndex

    That APCA output runs to plus or minus Lc 106 with the sign carrying polarity, and its published levels, including Lc 90 as the preferred level for body text at 14px regular, Lc 75 as the minimum for body text at 18px, and Lc 60 for non-body text at 24px regular or 16px bold.

  3. Peer-reviewedJournal of the Optical Society of America A

    That red-green colour vision deficiency affects roughly 8 percent of men and about 0.4 percent of women of European descent, with the prevalence varying substantially by ancestry.

Topics

  • Accessibility
  • WCAG
  • Color Contrast
  • Design
  • A11Y

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).

Get new tools by email

New tools and the occasional deep-dive, about once a month. No spam, no sharing your address, unsubscribe in one click.

Related articles

Article title card. Two outlined rectangles of the same size offset slightly from each other, one grey and one amber, with the line: the drift you cannot see by eye
CSS & Design

How to Compare a Design to Your Build, Pixel by Pixel

Overlay the mockup on the live page and let difference blending find the drift. The method, what each blend mode is for, and what the tools in this category actually cost.