WCAG Color Contrast Ratio Rules and How to Check Them
WCAG AA and AAA contrast ratios (4.5:1, 3:1, 7:1), what counts as large text, the 3:1 rule for buttons and icons, and how to check and fix them in DevTools.

A WCAG color contrast ratio is a number between 1:1 and 21:1 that says how much brighter one color is than another. For Level AA, the W3C's Understanding SC 1.4.3 Contrast (Minimum) requires at least 4.5:1 between normal text and its background, and at least 3:1 for large text. Level AAA raises those to 7:1 and 4.5:1.
Buttons, icons, input borders and focus rings fall under a separate rule, SC 1.4.11 Non-text Contrast, which asks for 3:1 against adjacent colors at Level AA. Those four numbers answer most "does this pass?" questions. The sections below cover what "large" means in pixels, why 4.49:1 fails, how browser DevTools measures it, and how to repair a palette that doesn't make the cut.
WCAG AA and AAA contrast ratios at a glance#
| What you're checking | Level AA | Level AAA |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text (18pt, or 14pt bold) | 3:1 | 4.5:1 |
| UI component boundaries and states (buttons, inputs, focus indicators) | 3:1 | |
| Graphics needed to understand content (icons, chart marks) | 3:1 | |
| Logos and purely decorative text | exempt | exempt |
Treat these as hard thresholds. The W3C note says computed values should not be rounded, so 4.499:1 does not meet 4.5:1.
The numbers aren't arbitrary. 3:1 is the minimum that ISO 9241-3 and ANSI/HFES 100-1988 recommend for standard text and normal vision. Someone with 20/40 acuity, which the W3C cites as typical at roughly age 80, has a contrast sensitivity loss of roughly 1.5, and 3 times 1.5 gives 4.5:1. The same logic for 20/80 vision gives about 7:1, which is where AAA comes from.
Hue is deliberately left out. People with color vision deficiency can't tell some hues apart, but their light-dark perception is largely intact, so the formula only compares brightness.
What a color contrast ratio actually measures#
The ratio is (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter color and L2 that of the darker. Relative luminance runs from 0 for black to 1 for white. For sRGB it's L = 0.2126 R + 0.7152 G + 0.0722 B, after each 8-bit channel is divided by 255 and linearized: values at or below 0.04045 are divided by 12.92, and anything higher goes through ((c + 0.055) / 1.055) ^ 2.4. The 0.05 term models typical viewing flare on a screen, and it's why the scale tops out at 21:1.
Here's the whole formula in plain Node, with two grays that sit right on the AA line:
function linear(channel) {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function luminance(hex) {
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
}
function contrast(a, b) {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
const pairs = [
['#767676', '#ffffff'],
['#777777', '#ffffff'],
['#1d4ed8', '#ffffff'],
['#ffffff', '#000000'],
];
for (const [fg, bg] of pairs) {
const ratio = contrast(fg, bg);
const text = ratio >= 4.5 ? 'AA text pass' : 'AA text FAIL';
const large = ratio >= 3 ? 'large/UI pass' : 'large/UI FAIL';
console.log(`${fg} on ${bg}: ${ratio.toFixed(3)}:1 ${text} ${large}`);
}
Two grays one step apart per channel can land on opposite sides of 4.5:1. That's the "don't round" note in practice.
The linearization constant used to be 0.03928 and was changed to 0.04045 in May 2021 to match the sRGB spec. The W3C says it has no practical effect on results, so older tools still agree with new ones.
What counts as large text#
Large text is at least 18 point, or at least 14 point if bold. WCAG points are CSS pt, and 1pt = 1.333px, so the thresholds are roughly 24px for regular weight and 18.5px for bold. A 20px semibold heading is normal text and needs 4.5:1. A 24px light heading is large text and only needs 3:1, though the W3C adds that very thin or unusual fonts may still need more.
Images of text follow the same ratios under SC 1.4.5, but point sizes inside image editors depend on pixel density (72ppi versus 96ppi). For images, make the text roughly 1.2 em or 1.5 em, which is 120% or 150% of the default body size. At 72ppi that works out to about 19pt and 24pt.
Non-text contrast for buttons, icons and focus rings#
Understanding SC 1.4.11 Non-text Contrast requires 3:1 between a component's visual boundary or state indicator and the adjacent colors, and the same 3:1 for graphics a reader needs to understand the content. The W3C's worked examples settle where "adjacent" is measured:
- A star rating filled with yellow (#FFF000) on white has a ratio of 1.2:1 and fails. A contrasting fill, or a thicker border, passes.
- A focus ring drawn outside a button must contrast with the page background. Yellow (#FFFF00) outside on white (#FFF) fails; green (#008000) outside on white passes.
- A focus ring drawn inside a button must contrast with the button's own background. Yellow inside a blue (#4189B9) button passes.
- Changing a button's border from blue (#4189B9) to green (#4B933A) on focus fails, because the new border sits inside the component and doesn't contrast with its interior.
- A focus state that only swaps the background color isn't judged by 1.4.11 at all, but it fails 1.4.1 Use of Color.
Two text-related cases land here instead of in 1.4.3. Characters used as symbols, like an X for close or a > for next, count as non-text content and need 3:1. And inline links distinguished from surrounding text by color alone need 3:1 against that text (technique G183), on top of the usual text-to-background ratio.
AAA contrast: when 7:1 is worth the trouble#
SC 1.4.6 Contrast (Enhanced) moves normal text to 7:1 and large text to 4.5:1. The W3C's reasoning is that people with worse than 20/80 vision usually use assistive technology with built-in contrast enhancement, so 7:1 covers readers who get by without it. The full text is in Understanding SC 1.4.6 Contrast (Enhanced).
My advice: make AA the floor for everything, and push long-form body text to 7:1, since dark gray on white gets there easily. Don't force 7:1 on brand-colored buttons if it wrecks them. Keep 3:1 as the target for component boundaries either way.
One allowance worth knowing: some people with cognitive disabilities need low-contrast schemes. A theme switcher that drops below the thresholds is fine, provided there's a mechanism to return to the conforming colors.
How to check contrast in browser DevTools#
Chrome DevTools can list every low-contrast text node on a page and suggest replacements. Its accessibility docs describe three ways to get the list:
- CSS Overview panel: capture an overview, open the Colors section, and look under Contrast issues.
- Issues tab (preview): open Settings > Experimental, search for "contrast issue", select "Enable automatic contrast issue reporting via the Issues panel", and reload DevTools.
- Lighthouse: open More tabs > Lighthouse, choose Mode: Navigation, Categories: Accessibility, Device: Desktop, click Analyze page load, and read the Contrast section.
Clicking an element in any of those lists selects it in the Elements panel. From there:
- Click Inspect and hover the element in the viewport. The tooltip shows the contrast ratio with a warning sign if it's low.
- Open the Color Picker next to the text's color declaration and expand the Contrast ratio section. It tells you whether the pair meets AA or AAA.
- Click Use suggested color next to the AAA row to apply a passing color, or drag the circle in the shades preview and stay below the AA or AAA line to pick your own.
Edits made this way live only in DevTools until you copy all CSS changes into your source, or set up a Workspace so DevTools saves to your files directly.
DevTools reads colors from CSS, not from rendered pixels, which matches the W3C guidance to evaluate the stylesheet colors rather than the anti-aliased result. It also means text over a gradient, photo or translucent layer may not be measured against the real backdrop. Check those by hand against the lightest and darkest parts of the background. If you'd rather paste two hex codes and get a verdict, the color contrast checker walkthrough covers that workflow.
Fixing a palette that fails#
The usual failure is a brand color that's fine as a button fill but too light as text on white. Three options, in the order I'd try them:
- Keep the hue and darken until it passes. Most brand blues survive a visible nudge; oranges and yellows usually turn into a different color.
- Use the brand color only where 3:1 is enough: large headings, button fills with white text (check the white-on-brand pair too), icons and borders.
- Add a darker "text" variant of each brand color and use it for anything under 24px.
This script does option 1. It scales each channel toward black or white until the rounded hex value passes the target, so the output is something you can paste straight into CSS:
function linear(channel) {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
const lum = ([r, g, b]) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
const toRgb = (hex) => { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const toHex = (rgb) => '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
const ratio = (a, b) => { const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
function fixForeground(fgHex, bgHex, target) {
const bg = toRgb(bgHex);
let fg = toRgb(fgHex);
const darken = lum(fg) < lum(bg);
let out = toHex(fg);
for (let step = 0; step < 600 && ratio(toRgb(out), bg) < target; step++) {
fg = fg.map((v) => (darken ? v * 0.98 : v + (255 - v) * 0.02));
out = toHex(fg);
}
return ratio(toRgb(out), bg) >= target ? out : null;
}
const background = '#ffffff';
const palette = { brandBlue: '#3b82f6', accentOrange: '#f97316', mutedGray: '#9ca3af' };
for (const [name, hex] of Object.entries(palette)) {
const before = ratio(toRgb(hex), toRgb(background)).toFixed(2);
const aa = fixForeground(hex, background, 4.5);
const aaa = fixForeground(hex, background, 7);
const show = (h) => (h ? `${h} (${ratio(toRgb(h), toRgb(background)).toFixed(2)}:1)` : 'unreachable');
console.log(`${name} ${hex} at ${before}:1 -> AA ${show(aa)}, AAA ${show(aaa)}`);
}
Once you have the fixed values, store them as named tokens such as color.text.brand so nobody reaches for the raw button color again. The design tokens primer shows a JSON layout for that.
Don't lean on brand guidelines as an excuse. Logos and logotypes are exempt; text set in a brand color is not.
Common mistakes and gotchas#
- Rounding up. 4.49:1 fails. Tools that display one decimal can hide this, so compare the unrounded value.
- Checking only the resting state. Placeholder text, hover text, focused text and tooltip text all need to pass.
- Declaring one color without the other. Setting a text color with no background color, or the reverse, is a failure because the user's default for the missing half is unknown. If neither is set, white is assumed as the background.
- Thin fonts that pass on paper. Anti-aliasing renders hairline strokes fainter than the CSS color. Pick a heavier weight or exceed the ratio.
- Hue-only state changes. A checkbox that goes from white to yellow fill with the same outline fails Use of Color, and a yellow-to-white edge fails Non-text Contrast.
- Outlined letters. A narrow stroke around glyphs counts as part of the letter when measuring. A wide halo that fills the counters counts as background.
- Ornate display type. Heavily stylized text acts as a graphic and is judged under 1.4.11, not 1.4.3.
- Red on black. The W3C advises against predominantly long-wavelength colors on dark backgrounds because of how protanopia shifts luminance.
Quick reference checklist#
- Normal text: 4.5:1 (AA), 7:1 (AAA).
- Large text, 18pt/24px or 14pt/18.5px bold: 3:1 (AA), 4.5:1 (AAA).
- Component boundaries, state indicators, focus rings, meaningful icons: 3:1 against adjacent colors.
- Links distinguished by color alone: 3:1 against surrounding text.
- Never round; 4.499:1 fails.
- Measure CSS colors in sRGB, with both text and background declared.
- Check placeholder, hover and focus text, plus images of text.
- Logos are exempt; brand colors used as text are not.
- In Chrome: CSS Overview or Lighthouse to find issues, then Color Picker > Contrast ratio > Use suggested color to fix them.
Quick answers
What is the minimum WCAG color contrast ratio?
At Level AA, normal text needs at least 4.5:1 against its background and large text needs 3:1. Component boundaries, focus indicators and meaningful icons also need 3:1 under SC 1.4.11.
What is the WCAG AAA color contrast ratio?
SC 1.4.6 Contrast (Enhanced) raises normal text to 7:1 and large text to 4.5:1. The W3C chose 7:1 to compensate for vision loss equivalent to about 20/80.
Did WCAG 2.2 change the color contrast ratio from 2.1?
The WCAG 2.2 Understanding pages for SC 1.4.3 and 1.4.11 list the same 4.5:1, 3:1 and 7:1 thresholds used under 2.1. What 2.2 adds is a AAA criterion, Focus Appearance, for the size and strength of focus indicators.
Why is color contrast important for accessibility?
Low vision, aging and color vision deficiency all reduce contrast sensitivity, and hue differences don't help those readers. Enough light-dark contrast lets them read without contrast-enhancing assistive technology.
Do buttons and icons have a WCAG contrast requirement?
Yes. SC 1.4.11 Non-text Contrast, Level AA, requires 3:1 between a component's visible boundary or state indicator and adjacent colors, and 3:1 for graphics needed to understand the content.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note