How to Fix Color Contrast Errors: WCAG AA Ratios Explained
Fix color contrast accessibility errors: WCAG AA needs 4.5:1 for text and 3:1 for large text. Failure data from 219 sites and a script for passing colors.
To fix a color contrast error, raise the contrast ratio between text and its background to at least 4.5:1 for normal text, or 3:1 for large text (24px and up, or 18.66px bold), as WCAG 2.2 Success Criterion 1.4.3 requires. Usually that means darkening the text or the background by a few lightness points, keeping the same hue so your brand still looks like your brand.
Low contrast is the most common accessibility failure on the web, and our own data confirms it. On 3 October 2026 we ran axe-core on 219 randomly sampled homepages. 64.4% had at least one contrast failure, more than any other rule. The good news is in the details: more than half of the failing text elements were between 3.5:1 and 4.49:1, close enough that a small shade change fixes them.
This guide covers how the ratio is calculated, how to find failures, the five patterns behind most of them, and a Python script that computes the nearest passing shade of your own brand color instead of telling you to "use black".
What WCAG Actually Requires
WCAG defines contrast as a ratio between the relative luminance of two colors, from 1:1 (identical) to 21:1 (black on white). Three success criteria use it:
| Criterion | Level | Applies to | Minimum ratio |
|---|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | Normal text | 4.5:1 |
| 1.4.3 Contrast (Minimum) | AA | Large text (≥24px, or ≥18.66px bold) | 3:1 |
| 1.4.6 Contrast (Enhanced) | AAA | Normal / large text | 7:1 / 4.5:1 |
| 1.4.11 Non-text Contrast | AA | UI component boundaries, focus indicators, meaningful icons and chart elements | 3:1 |
The W3C's Understanding SC 1.4.3 document also lists the exceptions. Text in logos, purely decorative text, and text in inactive (disabled) UI components don't need to meet the ratio. Placeholder text does need to, because users have to read it.
Two details trip people up:
- "Large" is measured in CSS pixels after rendering, not in your design file. A 22px heading is normal text and needs 4.5:1.
- The thresholds are hard lines. 4.49:1 fails. WCAG's definition of contrast ratio gives no rounding allowance, and automated checkers don't round up.
What 3,463 Failing Elements Tell Us
We drew 420 domains at random from Tranco list 94GG2 (ranks 1,001–50,000), loaded each homepage in Chromium at 1366×900, and kept the 219 that served a real page. We then ran axe-core 4.10.2 with the WCAG 2.0/2.1/2.2 A and AA rule tags. That is the same engine and tag set BugViso uses.
| Metric (219 homepages) | Result |
|---|---|
| Homepages with ≥1 color-contrast failure | 141 (64.4%) |
| Total failing text elements | 3,463 |
| Failures against the 4.5:1 (normal text) threshold | 3,380 (97.6%) |
| Failures against the 3:1 (large text) threshold | 83 (2.4%) |
| Median contrast ratio of failing elements | 3.63:1 |
Here is how far off the 3,380 normal-text failures actually were:
| Measured ratio | Share of failures | Typical fix |
|---|---|---|
| 4.0 – 4.49:1 | 21.6% | Darken by 1–3 lightness points; often invisible to the eye |
| 3.5 – 3.99:1 | 32.1% | Darken by about 5–10 points |
| 3.0 – 3.49:1 | 14.3% | A noticeably darker shade, or larger/bolder text |
| 2.0 – 2.99:1 | 22.4% | A new color: usually light gray or brand color on white |
| Below 2.0:1 | 9.7% | Usually a layering bug: white on a light image or background |
53.7% of failures were within one point of passing. (For the full picture of which WCAG rules fail most often, see our website accessibility statistics for 2026.) That changes how you should approach a contrast backlog. Most of it isn't a redesign problem. It's a token problem: one gray or one brand tint used in hundreds of places, sitting just under the line.
💡 Fix the token, not the element. Swap
--color-text-mutedin one place and you clear every component that uses it. A per-element fix list of 300 items is usually 3–5 color variables.
How the Contrast Ratio Is Calculated
The formula is short, and knowing it explains why some colors "look fine" and still fail:
L = 0.2126·R + 0.7152·G + 0.0722·B (R, G, B linearised from sRGB)
contrast = (L_lighter + 0.05) / (L_darker + 0.05)Green contributes 71.5% of perceived luminance and blue only 7.2%. That's why white text on a saturated blue can pass while white on a bright green or orange fails badly, even though both buttons look equally "bold" to a designer.
Some common colors, measured with the script later in this post:
| Combination | Ratio | AA normal text |
|---|---|---|
#767676 on white | 4.54:1 | ✅ lightest neutral gray that passes |
#777777 on white | 4.47:1 | ❌ one shade lighter, fails |
#6b7280 (Tailwind gray-500) on white | 4.83:1 | ✅ |
#9ca3af (Tailwind gray-400) on white | 2.53:1 | ❌ |
#94a3b8 (slate-400) on #f8fafc | 2.45:1 | ❌ |
White on #3b82f6 (blue-500) | 3.67:1 | ❌ passes only as large text |
White on #ef4444 (red-500) | 3.76:1 | ❌ passes only as large text |
White on #f97316 (orange-500) | 2.80:1 | ❌ fails even as large text |
White on #22c55e (green-500) | 2.27:1 | ❌ fails even as large text |
The #767676 vs #777777 pair shows how sharp the line is: one step of hex, and the result flips.
How to Find Contrast Errors on Your Site
In the browser. Open DevTools, select a text element, and click the color swatch in the Styles pane. Chrome shows the contrast ratio with AA/AAA indicators and draws the passing threshold on the color picker. That's fine for one element, but slow for a site.
For a whole page, run axe-core from the console and keep only the contrast results:
// DevTools console: load axe-core 4.10.2 and list every failing text element
const s = document.createElement('script')
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.2/axe.min.js'
s.onload = async () => {
const r = await axe.run(document, { runOnly: ['color-contrast'] })
console.table(r.violations.flatMap(v => v.nodes.map(n => {
const d = (n.any.find(c => c.id === 'color-contrast') || {}).data || {}
return { element: n.target.join(' '), fg: d.fgColor, bg: d.bgColor,
ratio: d.contrastRatio, needs: d.expectedContrastRatio }
})))
console.log('Needs manual review:', r.incomplete.flatMap(v => v.nodes.map(n => n.target.join(' '))))
}
document.head.appendChild(s)If the page has a strict Content Security Policy, the browser will refuse to load the script from the CDN. In that case, open the axe-core file, paste its contents into the console first, then run the axe.run part.
Pay attention to the "needs manual review" list. When text sits on a background image, a gradient or a semi-transparent overlay, axe can't compute a single background color, so it returns the element as incomplete instead of passing or failing it. Those elements are where the worst real-world contrast problems hide. Our guide to what automated accessibility testing can and can't catch explains why.
The 5 Patterns Behind Most Contrast Failures
1. Light gray "secondary" text
Captions, dates, footers and helper text set in #999, #aaa or a Tailwind -400 gray. This is the single most common failure we see.
/* ❌ 2.84:1 on white — fails */
.meta { color: #999999; }
/* ✅ 4.54:1 — the lightest neutral gray that passes on white */
.meta { color: #767676; }2. White text on bright brand buttons
Orange, green, yellow, light blue and pink buttons almost always fail with white text. You have two honest options: darken the button background, or switch the label to dark text. Our script computes the smallest shift that passes:
$ python3 contrast_fix.py "#ffffff" "#f97316" --fix bg
#ffffff on #f97316: 2.80:1 -> AA FAIL (needs 4.5:1) | AAA FAIL
Nearest passing bg: #c55405 (4.52:1, lightness moved 13.5 points)3. Placeholder text used as a label
Browsers default placeholder text to a light gray, and many design systems lighten it further. Placeholders must meet 4.5:1. More importantly, they must not replace a visible <label>, because the placeholder disappears as soon as someone types.
/* ✅ Passing placeholder on a white input */
input::placeholder { color: #6b7280; opacity: 1; } /* Firefox lowers opacity by default */4. Text on images and gradients
A white headline over a hero photo passes over the dark parts and fails over the sky. Don't rely on the image. Guarantee the contrast with a scrim:
/* ✅ Guarantees contrast regardless of the photo underneath */
.hero { position: relative; }
.hero::before {
content: ""; position: absolute; inset: 0;
background: linear-gradient(to top, rgb(0 0 0 / 0.65), rgb(0 0 0 / 0.15));
}
.hero h1 { position: relative; color: #fff; }5. Opacity stacking
opacity: 0.6 on a text container, or rgba() text colors, lowers the rendered contrast below what your design tokens say. Checkers measure the rendered color, so a token that passes on paper can fail on the page. Use solid, pre-mixed colors for text.
A Script That Finds the Nearest Passing Color
Most contrast checkers stop at "fail". This one keeps your hue and saturation and searches lightness for the closest shade that passes. That's usually what a designer will actually approve.
#!/usr/bin/env python3
"""WCAG contrast checker that also suggests the nearest passing colour.
Usage: python3 contrast_fix.py "#8a8a8a" "#ffffff"
python3 contrast_fix.py "#8a8a8a" "#ffffff" --large # >= 24px, or >= 18.66px bold
python3 contrast_fix.py "#ffffff" "#3b82f6" --fix bg # adjust the background instead
"""
import argparse, colorsys
def parse(hexstr):
h = hexstr.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
return tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
def to_hex(rgb):
return "#" + "".join(f"{round(c * 255):02x}" for c in rgb)
def luminance(rgb):
lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb]
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def fmt(r):
return f"{int(r * 100) / 100:.2f}" # truncate: 4.499 must never print as 4.50
def ratio(a, b):
la, lb = sorted((luminance(a), luminance(b)), reverse=True)
return (la + 0.05) / (lb + 0.05)
def nearest_passing(colour, against, target):
h, l, s = colorsys.rgb_to_hls(*colour)
for step in range(0, 1001):
for sign in (-1, 1):
nl = l + sign * step / 1000
if 0 <= nl <= 1:
cand = parse(to_hex(colorsys.hls_to_rgb(h, nl, s))) # test the hex you'd ship
if ratio(cand, against) >= target:
return cand, abs(nl - l)
return None, None
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("fg"); ap.add_argument("bg")
ap.add_argument("--large", action="store_true", help="large text: 3:1 threshold")
ap.add_argument("--fix", choices=["fg", "bg"], default="fg")
a = ap.parse_args()
fg, bg = parse(a.fg), parse(a.bg)
need = 3.0 if a.large else 4.5
r = ratio(fg, bg)
print(f"{a.fg} on {a.bg}: {fmt(r)}:1 -> AA {'PASS' if r >= need else 'FAIL'} (needs {need}:1)"
f" | AAA {'PASS' if r >= need + (1.5 if a.large else 2.5) else 'FAIL'}")
if r < need:
moving, fixed = (fg, bg) if a.fix == "fg" else (bg, fg)
cand, delta = nearest_passing(moving, fixed, need)
print(f"Nearest passing {a.fix}: {to_hex(cand)} ({fmt(ratio(cand, fixed))}:1, lightness moved {delta * 100:.1f} points)")Real output for the colors from the table above:
$ python3 contrast_fix.py "#9ca3af" "#ffffff"
#9ca3af on #ffffff: 2.53:1 -> AA FAIL (needs 4.5:1) | AAA FAIL
Nearest passing fg: #6e7788 (4.50:1, lightness moved 16.7 points)
$ python3 contrast_fix.py "#ffffff" "#3b82f6" --fix bg
#ffffff on #3b82f6: 3.67:1 -> AA FAIL (needs 4.5:1) | AAA FAIL
Nearest passing bg: #1e6ff5 (4.51:1, lightness moved 6.0 points)
$ python3 contrast_fix.py "#ffffff" "#22c55e" --fix bg
#ffffff on #22c55e: 2.27:1 -> AA FAIL (needs 4.5:1) | AAA FAIL
Nearest passing bg: #178841 (4.53:1, lightness moved 14.0 points)A 6-point lightness shift on a blue button is a change most stakeholders won't notice. Put it next to the original in a design review and the argument is usually over.
Fix It in Your Design Tokens
Because most failures come from a few shared colors, fix them at the token level and add a regression check so they can't come back:
:root {
/* ❌ Before */
--text-muted: #9ca3af; /* 2.53:1 on white */
--brand: #3b82f6; /* 3.67:1 behind white text */
/* ✅ After: same hues, passing lightness */
--text-muted: #6e7788; /* 4.50:1 */
--brand: #1e6ff5; /* 4.51:1 behind white text */
--brand-tint: #3b82f6; /* keep the original for non-text decoration only */
}
/* Respect users who ask the OS for more contrast */
@media (prefers-contrast: more) {
:root { --text-muted: #374151; }
}The prefers-contrast media query lets you offer an AAA-level palette to people who ask for it without redesigning the default theme.
Then test every theme. Dark mode has its own contrast failures, typically mid-gray text on near-black. A palette that passes in light mode guarantees nothing in dark mode.
How BugViso Detects Contrast Failures
BugViso runs a self-hosted axe-core engine with the WCAG 2.0, 2.1 and 2.2 A/AA rule tags on every audited page, in a real Chromium render. Contrast is therefore measured on the colors the browser actually painted: after CSS variables, opacity and inherited styles are applied.
For each color-contrast violation, the accessibility section of the report shows the failing element, its foreground and background colors, the measured ratio, and the ratio it needed. You can see at a glance whether a failure sits at 4.3:1 (a token tweak) or 1.8:1 (a layout bug). The same violations appear in the PDF report with the remediation playbook. A multi-page crawl also shows when one shared color fails across the whole site rather than on a single page.
You can scan any page with BugViso to get the full contrast list with measured ratios.
Edge Cases and Common Mistakes
- Disabled buttons are exempt, "looks disabled" buttons are not. If a button is clickable, it needs 4.5:1, however faded the design wants it.
- Hover and focus states count. A link that drops to 3:1 on hover fails while hovered. Checkers usually only test the resting state.
- Links inside paragraphs need more than color. If a link differs from surrounding text by color alone, WCAG 1.4.1 requires either a 3:1 difference from the body text or a non-color cue such as an underline. axe's
link-in-text-blockrule flagged this on 11% of homepages in our sample. - Icons and input borders fall under 1.4.11. A pale input border on white is a 1.4.11 failure (3:1 for component boundaries) even if every label passes 1.4.3. The W3C's Understanding SC 1.4.11 shows examples.
- Don't fix contrast with an overlay widget. Toolbar "high-contrast modes" don't change your default rendering, which is what every visitor and every audit sees. Contrast was the one failure the overlays we tested never fixed.
- Contrast is rarely the only problem. Sites with contrast failures usually also have unnamed links and missing alt text. Our guide to the most common WCAG violations covers the rest of the usual list.
FAQ
What contrast ratio do I need for WCAG AA?
4.5:1 for normal text and 3:1 for large text (at least 24px, or 18.66px bold). User-interface components and meaningful graphics need 3:1 against adjacent colors under SC 1.4.11.
Does color contrast affect SEO?
Not as a direct ranking factor. It affects whether people can read and act on your content, which shows up in engagement and conversions. Low-contrast text is also a common item in accessibility complaints and legal demand letters.
Is 4.4:1 close enough to pass?
No. WCAG thresholds have no rounding: 4.49:1 fails. In our data, 21.6% of all normal-text failures were between 4.0 and 4.49:1, which is exactly why the fix is often a one-shade change.
What is the lightest gray I can use on a white background?
#767676, at 4.54:1. #777777 measures 4.47:1 and fails. For large text, you can go as light as about #949494 (3:1).
Why does my checker say "needs review" instead of pass or fail?
The text sits on something the checker can't resolve to one color: a background image, a gradient, a pseudo-element or a transparent layer. Check those elements manually at their lightest point, or add a solid scrim behind the text.
Conclusion
Most contrast failures sit within a point of passing, so the fastest fix is almost always a slightly darker shade of the token you already use, and the free BugViso scan shows exactly which elements fail and by how much.
See where your site stands
Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.