WCAG color contrast: the practical guide
A working guide to color contrast in 2026. What WCAG 2 actually measures, why it's flawed, what APCA does differently, CSS strategies that work, and the patterns that fail accessibility audits.
Color contrast is one of those design topics where you can read for hours and still be unsure if your "almost-gray-on-slightly-different-gray" text actually passes accessibility standards. The math involved isn't hard, but the rules around it are surprisingly nuanced — and the standards are mid-transition, with the old WCAG 2 formula being widely used but increasingly recognized as flawed.
This post is a working guide to color contrast in 2026: what WCAG 2 actually measures, why it's flawed, what APCA (the proposed replacement) does differently, and how to ship interfaces that work for users with visual differences without descending into design despair.
What "contrast" actually measures
Contrast in WCAG is a ratio between two relative luminance values. Relative luminance is a perceptual approximation of "how bright is this color to a typical eye". Pure white has luminance 1.0; pure black has 0. Every other color is somewhere in between, but not linearly — the formula weights green channel heavily, red moderately, and blue less, because human eyes are most sensitive to green.
The WCAG 2 contrast ratio is:
contrast = (L1 + 0.05) / (L2 + 0.05)
where L1 is the lighter color's luminance,
L2 is the darker color's luminance.
The 0.05 offset is the "viewing flare" constant — it approximates how ambient light reflects off a screen, preventing the formula from going to infinity when one color is pure black.
The ratio ranges from 1:1 (identical colors, no contrast) to 21:1 (pure black on pure white). WCAG defines thresholds for compliance:
- 4.5:1 — Level AA for normal text (the "default" minimum)
- 3:1 — Level AA for large text (18pt+, or 14pt+ bold)
- 7:1 — Level AAA for normal text (stricter accessibility tier)
- 4.5:1 — Level AAA for large text
- 3:1 — Level AA for UI components and graphical objects (the 2.1 addition)
"Large text" specifically means 18pt (24px) regular weight or 14pt (~19px) bold. The threshold drops because larger glyphs are easier to read at a given contrast.
Computing it: the actual code
Relative luminance requires three steps: convert sRGB hex to 0-1 channels, apply gamma correction, and weight the channels:
function hexToRgb(hex) {
const m = hex.replace('#', '').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (!m) throw new Error('Invalid hex color');
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
}
function relativeLuminance([r, g, b]) {
// Convert sRGB (0-255) to linear RGB (0-1)
const toLinear = c => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
// Weight by human perception
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function contrastRatio(hex1, hex2) {
const L1 = relativeLuminance(hexToRgb(hex1));
const L2 = relativeLuminance(hexToRgb(hex2));
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
contrastRatio('#000000', '#FFFFFF') // 21
contrastRatio('#777777', '#FFFFFF') // 4.48 (just below AA!)
contrastRatio('#767676', '#FFFFFF') // 4.54 (just above AA)
The numbers 0.2126, 0.7152, 0.0722 are the ITU-R BT.709 luminance weights — the standard for HDTV-era sRGB. They're chosen to approximate how the human eye weights the three color channels.
Why WCAG 2 contrast is wrong (sometimes spectacularly)
The WCAG 2 formula has been known to be imperfect for years. The most-cited problems:
- It treats foreground/background symmetrically. Black text on a gray background should be easier to read than gray text on a black background of the same contrast ratio. WCAG 2 gives them the same score.
- It doesn't account for text size and weight properly. The 18pt cutoff for "large text" is binary — at 17.9pt you need 4.5:1; at 18pt you need only 3:1. Real legibility is continuous.
- Pure-color combinations score better than they read. Saturated yellow text on blue can "pass" WCAG 2 numerically while being unreadable in practice.
- Dark gray-on-black passes when it shouldn't. The flare constant inflates contrast at the dark end, making dark-on-dark combinations look better-than-they-look.
If you've ever found a passing-WCAG color combination that looks bad, or a failing one that reads fine — it's not you, it's the formula.
APCA: the next generation
APCA (Accessible Perceptual Contrast Algorithm) is the proposed replacement, currently in the WCAG 3 draft. It's perceptually-derived from contrast-sensitivity research and handles all the issues above:
- Foreground and background are not symmetric — APCA gives different scores depending on which color is on which.
- It produces a single number called Lc (Lightness contrast), ranging from -108 to +106. Positive means dark-on-light; negative means light-on-dark. The magnitude matters.
- Thresholds depend on font size AND weight, with a continuous lookup table rather than a binary "large/small" cutoff.
Typical APCA thresholds (simplified):
| Use case | Minimum |Lc| |
|---|---|
| Body text (14-16px regular) | 75 |
| Body text (18px regular) | 60 |
| Headings (24px+ semibold) | 45 |
| Large headings (36px+ bold) | 30 |
| UI components, icons | 30 |
| Non-essential decorative | 15 |
APCA is more conservative than WCAG 2 for body text and more permissive for large headings, which matches how most designers and accessibility researchers feel readability actually works.
Practical recommendations
The state of the art in 2026: use APCA in design, verify with WCAG 2 for legal compliance. Most accessibility lawsuits cite WCAG 2 AA, which is still the formal standard. APCA gives you better-looking interfaces; WCAG 2 keeps you out of court.
You can test both with our color contrast checker — it shows the WCAG 2 ratio, the AA/AAA pass/fail for normal and large text, and the APCA Lc score for comparison.
CSS strategies that work
The simplest path to accessible contrast: use a color system with pre-defined steps that pass at typical text sizes. Many design systems do this — Tailwind's gray-900 on white, Material Design's surface system, GitHub's color tokens — each step is sized so adjacent levels pass AA.
:root {
/* Text colors with sufficient contrast against white */
--text-1: #111827; /* 17.6:1 on white — AAA */
--text-2: #374151; /* 11.4:1 on white — AAA */
--text-3: #6B7280; /* 4.62:1 on white — AA */
--text-disabled: #9CA3AF; /* 2.85:1 — fails AA, OK for non-essential */
--bg: #FFFFFF;
--surface: #F9FAFB;
--border: #E5E7EB;
}
[data-theme="dark"] {
--text-1: #F9FAFB;
--text-2: #D1D5DB;
--text-3: #9CA3AF;
--bg: #111827;
--surface: #1F2937;
}
Build your design with these tokens; trust the contrast math. Custom one-off colors are where contrast bugs hide.
Common patterns that fail accessibility
- Placeholder text in form inputs. Default browser placeholder color is light gray (about 2.5:1 on white). Many designs make it even lighter. Use a darker placeholder (4.5:1 minimum) or, better, use a proper floating label.
- Disabled buttons. Disabled state typically reduces opacity, which kills contrast. WCAG 2.1 explicitly exempts disabled UI from contrast requirements, but if a disabled button is in your most common state, consider whether you can use a different visual treatment that's still distinguishable.
- Help text and captions. The tendency to make secondary text light-gray-on-white below 4.5:1 is widespread.
#9CA3AFon white is 2.85:1 — fails AA. - Charts and data viz. The default color palettes in most charting libraries don't meet 3:1 for distinguishing categories when seen by users with color blindness. Use ColorBrewer palettes or add patterns/markers.
- Focus indicators on light backgrounds. Default browser focus ring is often a low-contrast blue. Make focus rings clearly visible (3:1 contrast minimum) — keyboard users depend on them.
Color blindness: a separate consideration
Contrast ratios are about luminance — about whether colors are bright enough apart. Color blindness is about distinguishing hues. They're related but not the same.
The most common form is red-green color blindness (about 8% of men, 0.5% of women have some form). Red text and green text with the same luminance look identical. Two design rules:
- Never use color alone to convey information. Red and green indicators for "error" and "success" should also have icons, prefixes, or different positions. The "X" / "✓" pair is universal.
- Test with simulators. Chrome DevTools → Rendering → Emulate vision deficiencies. Switch between Protanopia, Deuteranopia, Tritanopia, and Achromatopsia. Critical UI states should remain distinguishable in all four modes.
Testing strategy
Tools you should know about, in order of usefulness:
- Browser DevTools accessibility audit. Chrome's Lighthouse and the Accessibility panel in DevTools both flag low-contrast text in pages. Built-in, free, instant.
- axe DevTools extension. More thorough than Lighthouse, fewer false positives. Free for the browser version.
- WCAG and APCA calculators (like ours) for designing color tokens.
- Manual review with vision-deficiency simulation. DevTools rendering settings or the Sim Daltonism app.
- Real user feedback. Nothing replaces actual users with visual differences telling you what works.
For new projects: build color tokens that pass AA at typical text sizes (4.5:1 for body), verify with automated tools, simulate color-blindness modes during design review. For existing projects: run axe on your top-traffic pages, fix the failures, and add automated contrast checks to your CI pipeline.
The bottom line
Accessible contrast is mostly solved by using a well-designed color system and trusting it. Custom color combinations are where bugs hide. When in doubt:
- 4.5:1 for body text. Stricter is fine.
- 3:1 for large text and UI components.
- Don't rely on color alone for state or category information.
- Test with vision deficiency simulators before shipping.
- Add automated contrast checks to CI for regressions.
Most accessibility problems aren't from negligence — they're from designers picking colors that look good in isolation without checking the contrast math. A 60-second check with the right tool prevents most of these from shipping.