WCAG Contrast Ratios: How AA and AAA Are Calculated (2026)
A developer's guide to the WCAG contrast ratio formula, the 4.5:1 AA and 7:1 AAA thresholds, large-text exceptions, and how to check color contrast in code.
WCAG Contrast Ratios: How AA and AAA Are Calculated
If a designer hands you #777 text on white and asks "does this pass accessibility?", you need a number, not a vibe. The WCAG contrast ratio turns two colors into a single value between 1:1 and 21:1, and the answer here is 4.48:1 — which quietly fails Level AA by two hundredths of a point. This guide covers the formula, the exact AA and AAA thresholds, the exceptions people miss, and how to compute it yourself.
TL;DR
- WCAG contrast ratio is
(L1 + 0.05) / (L2 + 0.05), ranging from 1:1 to 21:1. - Level AA needs 4.5:1 for normal text, 3:1 for large text and non-text.
- Level AAA raises those to 7:1 and 4.5:1 respectively.
- Ratios are thresholds — 4.49:1 fails 4.5:1, no rounding allowed.
- WCAG 2.2 luminance math is still the 2026 standard; APCA is not.
What is a WCAG contrast ratio?
Contrast in WCAG is a light-dark relationship, not a color relationship. Two colors with the same hue but different lightness can have a huge ratio; two vivid but equally-light colors can have almost none. This is deliberate — it makes the metric work for people with color vision deficiency, for whom hue and saturation have little effect on legibility.
The (L1 + 0.05) / (L2 + 0.05) formula
The WCAG 2.2 definition is compact:
ratio = (L1 + 0.05) / (L2 + 0.05)
L1 is the relative luminance of the lighter color and L2 the darker one. The 0.05 term models ambient viewing flare — real screens are never perfectly black — and it is why the ratio maxes out at 21:1 (pure black on pure white) instead of dividing by zero.
How relative luminance is calculated
Relative luminance normalizes brightness to 0 for black and 1 for white. Per the sRGB definition WCAG references, you gamma-expand each channel, then take a weighted sum that reflects how sensitive the eye is to each primary — green dominates, blue barely counts:
function channel(c) {
c = c / 255;
return c <= 0.04045
? c / 12.92
: Math.pow((c + 0.055) / 1.055, 2.4);
}
function luminance(r, g, b) {
return 0.2126 * channel(r)
+ 0.7152 * channel(g)
+ 0.0722 * channel(b);
}
Those coefficients (0.2126, 0.7152, 0.0722) are not arbitrary — they come from the sRGB color space, and green carries roughly 72% of perceived brightness. That is also why green text on white is far harder to make pass than you would expect.
Why the range is 1:1 to 21:1
Identical colors give 1:1 (no contrast). Black #000000 on white #ffffff gives exactly 21:1 — the ceiling. Every real color pair lands somewhere between. There is no "negative" contrast in WCAG 2; swapping foreground and background gives the same ratio, because the formula always divides the lighter luminance by the darker.
What contrast ratio do I need for WCAG AA?
Level AA is the bar almost everyone is legally held to — it is the level referenced by the ADA-related settlements, the European EN 301 549 standard, and most procurement rules. For Success Criterion 1.4.3 Contrast (Minimum), the requirement splits by text size.
Normal text vs large text
Normal body text needs 4.5:1. Large text needs only 3:1, because larger, thicker letterforms stay legible at lower contrast. Two quick, verified examples against white:
#767676on white is 4.54:1 — passes AA for normal text.#777777on white is 4.48:1 — fails, despite being one hex step lighter.
That razor-thin margin is exactly why you should compute the number instead of eyeballing it. Grab a value from the color picker and converter and check it before shipping.
What counts as large text
Per WCAG, "large scale" means at least 18 point, or 14 point bold. Because CSS pixels and points differ (1pt ≈ 1.333px), the W3C notes that 14pt and 18pt work out to roughly 18.5px and 24px. So a 24px heading can use 3:1, but your 16px paragraph text cannot — it is firmly in the 4.5:1 bucket.
Non-text contrast (1.4.11)
A criterion people forget: icons, input borders, focus rings, and the meaningful parts of charts fall under Success Criterion 1.4.11 Non-Text Contrast, which requires 3:1. A pale-grey input outline of #949494 on white sits at 3.03:1 — just barely legal. Drop it any lighter and your form fields become invisible to low-vision users even though the label text passes.
WCAG AA vs AAA: what's the difference?
AAA is not "AA but nicer" — it is a distinct, higher tier that most sites cannot fully reach across every screen.
The 7:1 enhanced threshold (1.4.6)
Success Criterion 1.4.6 Contrast (Enhanced) raises normal text to 7:1 and large text to 4.5:1. For example, #595959 on white is exactly 7.00:1 — the lightest grey that still clears AAA for body copy. Anything lighter needs a size bump or a darker background.
Why 4.5:1 and 7:1 were chosen
The W3C rationale is refreshingly concrete. A ratio of 3:1 is the baseline for normal vision from the ISO 9241-3 and ANSI/HFES standards. Empirically, someone with 20/40 acuity loses about 1.5× contrast sensitivity, so 3 × 1.5 = 4.5:1 — that is AA. Following the same logic, 20/80 vision needs about 7:1 — that is AAA. Per the W3C, 20/40 is "commonly reported as typical visual acuity of elders at roughly age 80," which reframes AAA as an aging-population concern, not an edge case.
When AAA is realistic
AAA is achievable for individual components — dark text on white clears it trivially. It gets hard for brand colors, disabled states, data visualizations, and anything using a mid-tone accent. A common, pragmatic policy: hit AA everywhere as a hard floor, and push body text toward AAA where the palette allows. Build the palette itself with a tool like the color palette generator so you can audit every swatch's luminance up front.
How to check color contrast without guessing
Calculating contrast in JavaScript
Combine the luminance helper above with the ratio formula:
function contrast(fg, bg) {
const L1 = luminance(...fg);
const L2 = luminance(...bg);
const hi = Math.max(L1, L2);
const lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
// contrast([0,0,0], [255,255,255]) -> 21
// contrast([118,118,118], [255,255,255]) -> 4.54
One rule the spec is emphatic about: do not round. The 4.5:1 and 3:1 values are thresholds, and the W3C states plainly that "4.499:1 would not meet the 4.5:1 threshold." Truncate for display if you like, but compare with full precision.
Common failures that quietly break AA
The two documented failure patterns catch most teams:
- Setting one color but not the other. Specifying text color without a background color (or vice versa) is an official failure — the user's default could be anything, so the pair is untestable.
- Text over a background image or gradient. If the image is busy, some regions may pass and others fail. Test the worst-case pixel behind the text, or add a solid scrim. If you are generating backgrounds, the gradient generator lets you preview where text will land on the light-to-dark sweep.
Other quiet killers: placeholder text (it must meet contrast too), hover and focus states, and thin "unusual" fonts that anti-alias fainter than their declared color.
Contrast thresholds at a glance
| Text / element | AA min | AAA min |
|---|---|---|
| Normal text (< 18pt / 14pt bold) | 4.5:1 | 7:1 |
| Large text (≥ 18pt / 14pt bold) | 3:1 | 4.5:1 |
| Icons, borders, focus rings | 3:1 | 3:1 |
| Disabled / inactive controls | none | none |
Note the last row: inactive components (a greyed-out submit button) have no contrast requirement at all under WCAG.
Is WCAG 2 contrast being replaced by APCA?
Short answer for 2026: no, not for conformance. You will hear a lot about APCA (Advanced Perceptual Contrast Algorithm), and it is genuinely better in some ways — it accounts for font size and weight and outputs a signed lightness-contrast value (Lc) rather than a symmetric ratio.
What APCA changes
Unlike WCAG 2's luminance ratio, APCA treats dark-on-light and light-on-dark differently and factors in how thin the text is. The catch, as accessibility specialist Adrian Roselli documents, is that APCA scores are not backwards-compatible with WCAG 2 numbers — a pair that passes 4.5:1 can fail APCA and vice versa.
Should you switch now
WCAG 3 is still a Working Draft in 2026, and its final contrast method is undetermined. That makes WCAG 2.2's 4.5:1 / 7:1 ratios the only defensible conformance target today. It is reasonable to sanity-check designs against APCA to catch cases the ratio misses (like thin light text on white), but ship against WCAG 2.2. Anchoring your design tokens to the luminance formula now means you will not have to re-audit everything when WCAG 3 lands.
References
- Understanding SC 1.4.3: Contrast (Minimum) — W3C WAI — AA thresholds, large-text definition, the rationale for 4.5:1 and 7:1, and the failure conditions.
- WCAG 2.2 Recommendation — contrast ratio definition — the normative
(L1+0.05)/(L2+0.05)formula and relative-luminance coefficients. - Understanding SC 1.4.6: Contrast (Enhanced) — W3C WAI — the AAA 7:1 / 4.5:1 requirements.
- WebAIM: Contrast and Color Accessibility — practical guidance on applying the ratios and common testing pitfalls.
- WCAG3 Contrast as of April 2026 — Adrian Roselli — current status of APCA and the WCAG 3 draft.
Related on iKit
- Convert any HEX code to RGB before you compute luminance — the contrast formula needs 0–255 channel values, so start by decoding your hex colors.
- Go the other way, from RGB back to HEX — once you have tuned a passing color numerically, convert it back for your stylesheet.
- Use OKLCH to build a contrast-aware palette — OKLCH's lightness axis maps closely to perceived brightness, making it easier to hit target ratios.
- Why design systems are moving from HSL to OKLCH — HSL lightness is uneven across hues, which is exactly why HSL palettes fail contrast unpredictably.
Related posts
OKLCH in CSS: A Practical Guide for Developers (2026)
OKLCH in CSS gives you perceptually uniform color, wider P3 gamut, and predictable lightness. Here is the syntax, the math, and the fallbacks for 2026.
RGB to HEX: The 2026 Guide for Designers and Developers
RGB to HEX is base-10 to base-16 on three bytes. Here's the exact math, the JavaScript one-liners, alpha channels, and the mistakes that trip people up.
Why str.length Lies About Emoji in JavaScript (2026)
In JavaScript str.length counts UTF-16 code units, so one emoji reports 2 and a family emoji reports 11. Here is how to count emoji characters correctly.