Color contrast is the most frequently reported WCAG violation in automated audits. WebAIM's 2024 analysis of the top 1,000,000 home pages found that low-contrast text was detected on 81.0% of sites. The good news: 90% of contrast failures fall into 6 repeatable patterns. Fix those and your scan score jumps overnight.
1. Placeholder text that disappears into the input
Default browser placeholders render at roughly #757575 on #ffffff, which clocks 4.48:1 — fails WCAG 2.1 SC 1.4.3 (AA, needs 4.5:1). Most design systems override placeholders to a lighter grey and silently break compliance.
Before (fails, 3.5:1):
input::placeholder { color: #9ca3af; } /* Tailwind gray-400 on white */
After (passes, 4.83:1):
input::placeholder { color: #6b7280; } /* Tailwind gray-500 on white */
Tailwind users: placeholder:text-gray-500 passes on white backgrounds. placeholder:text-gray-400 does not.
2. Brand-colored buttons with white text
Brand greens, oranges, and yellows are the usual offenders. A primary call-to-action like #22c55e (Tailwind green-500) on white text returns 2.18:1 — failing both normal and large text thresholds.
Quick fix: shift the brand color down 100–200 steps in your scale for any button surface that contains text.
/* Fails: 2.18:1 */
.btn-primary { background: #22c55e; color: white; }
/* Passes: 4.54:1 */
.btn-primary { background: #15803d; color: white; }
If marketing won't let you darken the brand color, dark text on a light tint of the same hue is a frequent compromise:
.btn-primary { background: #dcfce7; color: #14532d; } /* 9.4:1, AAA */
3. Sale price tags (red on pink)
Especially common on e-commerce templates. The default red #ef4444 on a pink chip #fee2e2 returns 3.4:1 — fails for normal text. This pattern accounts for a measurable share of e-commerce contrast failures in the audits we run.
Fix: use a darker red for the text, keep the chip light:
.sale-tag { background: #fee2e2; color: #991b1b; } /* 6.6:1, AA */
If you want to scan your site for this pattern and 50+ other WCAG checks, try AccessProof's free scanner — it points to the exact node, the computed ratio, and the WCAG level that fails.
4. Disabled states that lie about being disabled
WCAG 1.4.3 explicitly exempts disabled UI components from the contrast rule (Understanding SC 1.4.3, “inactive” clause). But the exemption only applies if the disabled state is unambiguously communicated to assistive tech.
If your “disabled” button is just visually greyed out with no aria-disabled or disabled attribute, screen reader users still treat it as interactive — and the contrast rule applies again.
<!-- Fails contrast AND deceives screen readers -->
<button class="bg-gray-200 text-gray-400">Submit</button>
<!-- Exempt: state is programmatically communicated -->
<button disabled class="bg-gray-200 text-gray-400">Submit</button>
5. Text over hero images
Contrast is measured pixel-by-pixel against the actual background — not the average. A white heading on a hero photo can pass on the dark left third and fail on the bright right third. Automated scanners often flag this even when it “looks fine” in design review.
Reliable fix: a semi-transparent dark overlay on the image. rgba(0,0,0,0.4) brings most photos under control without crushing the visual.
.hero {
background-image:
linear-gradient(rgba(0,0,0,.45), rgba(0,0,0,.45)),
url(/hero.jpg);
}
.hero h1 { color: white; } /* now reliably >7:1 */
For text-heavy sections, prefer a solid background block behind the text rather than relying on the image to behave.
6. Focus rings that vanish
WCAG 1.4.11 (Non-text Contrast, AA) requires UI components — including focus indicators — to have at least 3:1 contrast against adjacent colors. Removing the default outline without replacing it is the single most common keyboard-accessibility failure.
/* Catastrophic: removes focus indication entirely */
*:focus { outline: none; }
/* Compliant: visible, high contrast, matches brand */
:focus-visible {
outline: 2px solid #1142ff;
outline-offset: 2px;
}
Use :focus-visible (not :focus) so mouse users don't see the ring on click while keyboard users still get it on Tab.
How to verify your fixes
- WebAIM Contrast Checker (free, single pair) — sanity check the ratio you computed.
- axe DevTools browser extension — find every failing node on a page.
- AccessProof — scan the whole site against WCAG 2.2, get a dated PDF with every node and computed ratio. Re-scan after the fix to confirm. See pricing.
One pattern to adopt: a contrast-aware design token set
Rather than chasing failures, define your palette so failures are impossible. For every accent color, ship at least one paired text color that hits 4.5:1 and one that hits 3:1 — and use only those pairings in components.
/* tokens.css */
:root {
--accent: #1142ff; /* brand */
--accent-on-light: #0b2fb8; /* 7.0:1 on white */
--on-accent: #ffffff; /* 8.2:1 on --accent */
}
Reviewers stop asking “is this readable?” because the system can't produce an unreadable combination.
Testing a fix in 30 seconds
Before re-running a full scan, sanity-check a fix with the WebAIM Contrast Checker or your browser's DevTools color picker (Chrome and Firefox both display the computed ratio inline when you select a foreground color in the Inspector). If the ratio meets the threshold in the inspector, the change will hold at scan time.
One subtlety: the inspector shows the ratio for the styled foreground/background pair. If your background is actually an inherited color or a stacked semi-transparent surface, the inspector and the scanner may compute different effective backgrounds. axe-core walks the layout to find the actual rendered background pixel; that's why a fix that “looks fine” in DevTools can still fail an audit. When that happens, simplify the stack: replace stacked transparencies with a single opaque color, or place a wrapper with an explicit background behind the text.
The math behind the ratios
The WCAG contrast ratio is a relative luminance calculation defined in WCAG 2.1 §1.4.3. The formula:
ratio = (L1 + 0.05) / (L2 + 0.05)
where L1 = luminance of the lighter color, L2 of the darker.
Luminance itself is computed by linearizing each sRGB channel and weighting them 0.2126·R + 0.7152·G + 0.0722·B. You don't need to do this by hand — every modern tool implements it — but understanding it explains two common surprises:
- Green contributes ~72% of perceived brightness. That's why brand greens often pass thresholds that brand blues fail.
- The
+ 0.05term means very dark and very light pairs plateau. Going from#000to#0a0a0aagainst white barely moves the ratio.
Common Tailwind pairings that pass and fail
For teams that live in Tailwind, here's a quick reference (background → text):
| Background | Text | Ratio | WCAG AA normal |
|---|---|---|---|
| white | gray-400 | 3.0:1 | fail |
| white | gray-500 | 4.83:1 | pass |
| white | gray-600 | 7.0:1 | pass (AAA) |
| white | blue-500 | 4.0:1 | fail |
| white | blue-600 | 5.2:1 | pass |
| green-500 (#22c55e) | white | 2.18:1 | fail |
| green-700 (#15803d) | white | 4.54:1 | pass |
| red-100 | red-800 | 6.6:1 | pass |
If your design system standardizes on these pairings, contrast violations stop appearing in your audits.
The 24-pixel rule for touch targets (related but distinct)
Strictly speaking, target size is WCAG 2.5.8 (AA), not a contrast rule — but it's the issue that hides next to contrast failures on most automated reports, so we cover it here. Interactive elements must be at least 24×24 CSS pixels.
The most common offender: social media icons in the footer rendered at 16×16. Even when the contrast of the icon itself passes, the target size fails, and screen-pixel-level tools group them in the same “low-impact UI” bucket.
/* Fails 2.5.8 */
.social-icon { width: 16px; height: 16px; }
/* Passes — uses padding to reach 24x24 without resizing the icon */
.social-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
If your social icons live in tight footer rows, the padding-based approach keeps the visual size intact while meeting the rule.
Patterns we keep seeing in real audits
Across hundreds of scans, six anti-patterns account for the lion's share of contrast failures:
- Gray-on-gray secondary text — designers reaching for “subtle” tones without checking the ratio.
- Pastel buttons with white text — pastels saturate but don't darken; white text always loses.
- Underline-less link colors matching surrounding text within 3:1 — fails WCAG 1.4.1 (color isn't the only differentiator) if you also rely on color alone.
- Translucent overlays in modal headers that drop the effective contrast below threshold.
- Toast notifications with colored text on colored backgrounds — green success on light green is the canonical example.
- Form helper text below inputs in
#9ca3af. Bump to#6b7280or darker.
Train one designer and one engineer on these patterns and you'll prevent ~40% of contrast issues at the design-review stage.
What about dark mode?
Most contrast bugs hide in dark mode because designers eyeball it less. Run the same six checks in your dark theme. A typical failure: bright accent colors that worked on white are now too saturated against a near-black background, especially blues and purples that lose perceived contrast on dark surfaces.
If you toggle dark: variants in Tailwind, make sure every text-* color in a component has a corresponding dark mode pair tested against your bg-gray-900 or whatever your dark surface is.
Summary
Color contrast is solved by patterns, not heroics. Fix placeholders, brand buttons, sale tags, disabled states, hero text, and focus rings — you'll close the majority of contrast issues a typical audit will find. Adopt a contrast-aware token set and the issue stops recurring. Re-scan, generate a dated report, and move on to the harder stuff.