wave-contrast-audit
WAVE Contrast Audit
Systematically find and fix every color contrast violation on a live page using axe-core for precise data, then trace failures back to CSS source rules.
When to Use This Skill
- A WAVE report shows contrast errors on a deployed page
- User shares a WAVE report URL (
wave.webaim.org/report#/...) - Asked to "fix contrast issues" or "make the site WCAG AA compliant"
- After a design system token change that may have introduced failures
Core Workflow
Step 1 — Navigate to the live page (not the WAVE report)
Always run axe-core on the actual page, not the WAVE report wrapper.
Navigate to: https://example.com/page.html
Step 2 — Inject and run axe-core for exact color pairs
This gives you precise foreground/background hex values, contrast ratios, CSS selectors, and font sizes — far more actionable than clicking WAVE icons one by one.
async () => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js';
document.head.appendChild(script);
await new Promise(r => script.onload = r);
const results = await axe.run({ runOnly: ['color-contrast'] });
// Deduplicate by unique fg+bg pair
const pairs = {};
for (const v of results.violations) {
for (const node of v.nodes) {
for (const rel of node.any) {
if (rel.data?.fgColor) {
const key = `${rel.data.fgColor}__on__${rel.data.bgColor}`;
if (!pairs[key]) {
pairs[key] = {
fg: rel.data.fgColor,
bg: rel.data.bgColor,
ratio: rel.data.contrastRatio,
fontSize: rel.data.fontSize,
fontWeight: rel.data.fontWeight,
required: rel.data.expectedContrastRatio,
count: 0,
selector: node.target[0]
};
}
pairs[key].count++;
}
}
}
}
return {
total: results.violations.reduce((a, v) => a + v.nodes.length, 0),
pairs: Object.values(pairs).sort((a, b) => a.ratio - b.ratio)
};
}
Step 3 — Interpret the results
For each unique pair, note:
- fg + bg — the actual rendered hex values (rgba already resolved to hex)
- ratio — current contrast (e.g. 3.1:1)
- required — 4.5 (normal text) or 3.0 (large text >= 18px / >= 14px bold)
- selector — CSS selector pointing to the failing element
- count — how many elements share this exact pair
Step 4 — Find the CSS source rules
Use Grep to locate every CSS rule producing the failing color:
# Search for the failing value (rgba or hex)
grep -n "rgba(255,255,255,.45)\|#7a7878\|var(--steel)" src/styles.css
# Or search by selector
grep -n "\.sb-num\|\.btn-water" src/styles.css
Read the surrounding context to confirm background color and element role.
Step 5 — Calculate compliant replacement values
For white text on dark backgrounds (rgba(255,255,255, A)):
| Required | Min alpha on #022b45 |
Min alpha on #034f7d |
Min alpha on #7a4f99 |
|---|---|---|---|
| 4.5:1 | >= 0.75 (use 0.82) | >= 0.75 (use 0.82) | >= 0.85 (use 0.88) |
| 3.0:1 | >= 0.50 (use 0.60) | >= 0.52 (use 0.60) | >= 0.60 (use 0.68) |
For brand blue on light backgrounds:
#1499e8on white = 3.1:1 (fail) — use#0d77c4for button bg (4.72:1)#1499e8as text on white — use#0d77c4
For brand blue as text on dark backgrounds:
#1499e8on#022b45= 2.8:1 (fail) — use#8accf4(~5.0:1)
For muted gray on white/cream:
#7a7878on white = 4.4:1 (fail) — darken to#686666(5.7:1)#7a7878on#ededed= 3.7:1 (fail) —#686666also passes cream (4.9:1)
Manual luminance calculation (when needed):
Contrast = (L_lighter + 0.05) / (L_darker + 0.05)
L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
R_lin = ((R/255 + 0.055) / 1.055)^2.4 for values > 0.04045
R_lin = (R/255) / 12.92 for values <= 0.04045
Step 6 — Apply fixes efficiently
Batch all changes in a single Python pass to avoid making 50+ Edit calls:
with open('styles.css', 'r') as f:
css = f.read()
changes = [
('--steel: #7a7878', '--steel: #686666'),
('.btn-water { background: var(--vibrant-blue)',
'.btn-water { background: #0d77c4'),
# Add targeted string replacements for each failing rule...
]
results = []
for old, new in changes:
if old in css:
css = css.replace(old, new)
results.append(f" changed: {old[:50]}")
else:
results.append(f" NOT FOUND: {old[:50]}")
with open('styles.css', 'w') as f:
f.write(css)
print('\n'.join(results))
Warning:
rgba(255,255,255,.X)appears in backgrounds, borders, and box-shadows — not only text color. Always read context before replacing. Use targeted string matches (include surrounding lines) rather than broad single-value replacements.
Step 7 — Verify fixes
Re-run the Step 2 script on the updated page. Target: 0 color-contrast violations.
If the file is only local (not yet deployed), navigate Playwright directly:
Navigate to: file:///path/to/page.html
Common Failure Patterns
| Pattern | Failing value | Fix |
|---|---|---|
| Sidebar nav links on dark bg | rgba(255,255,255,.5-.62) |
Raise to .82 |
| Section labels on dark bg | rgba(255,255,255,.18-.25) |
Raise to .75 |
| Body text on dark bg | rgba(255,255,255,.52-.62) |
Raise to .85 |
| Small labels/captions on dark bg | rgba(255,255,255,.38-.45) |
Raise to .82 |
| Any text on purple bg (#7a4f99) | alpha < .85 | Raise to .88 |
| CTA button (white on #1499e8) | 3.1:1 | Change bg to #0d77c4 |
| Muted gray on white | #7a7878 |
Darken to #686666 |
| Brand blue as text on white | #1499e8 |
Change to #0d77c4 |
| Brand blue as text on dark | #1499e8 |
Change to #8accf4 |
| Brand palette 25% tint on color bg | e.g. #ded3e6 on purple |
Use var(--white) |
WCAG 2.2 AA Thresholds (Quick Reference)
| Text size | Required ratio |
|---|---|
| Normal (< 18px regular, < 14px bold) | 4.5 : 1 |
| Large (>= 18px regular, >= 14px bold) | 3.0 : 1 |
| UI components and icons | 3.0 : 1 |
WAVE False Alarms & How to Avoid Them
WAVE evaluates the DOM after JavaScript runs, at the initial scroll position (top of page). This causes predictable false positives that must be distinguished from real failures.
1 — GSAP scroll animations (opacity: 0 → reported as 1:1 contrast)
gsap.fromTo() immediately applies the FROM state as an inline style the moment the script runs. Every element targeted by a scroll-triggered fade-in gets opacity: 0 before any scrolling occurs. WAVE computes the rendered text color including opacity, so color: #022b45 at opacity: 0 becomes rgba(2,43,69,0) — a 1:1 contrast failure.
Diagnosis: Click the WAVE error icon — the element disappears. WAVE's code panel shows color: rgba(r,g,b,0) with alpha = 0.
Fix: Replace opacity with GSAP's autoAlpha in all content animation vars.
// BEFORE — causes WAVE false alarms
gsap.set('.hero-title', { opacity: 0, y: 30 });
gsap.to('.hero-title', { opacity: 1, y: 0, duration: 1 });
// AFTER — WAVE skips visibility:hidden elements, no false alarm
gsap.set('.hero-title', { autoAlpha: 0, y: 30 });
gsap.to('.hero-title', { autoAlpha: 1, y: 0, duration: 1 });
autoAlpha: 0 sets opacity: 0; visibility: hidden. WAVE's documented rule: "Errors are only reported on elements visible to users" — visibility: hidden is explicitly excluded from contrast checks.
Apply to all gsap.set, gsap.fromTo, and gsap.to calls that fade content in. Do not apply to background image wrappers or decorative elements that already use aria-hidden.
2 — Background-image elements (reported as 1:1 contrast)
Elements that use a child div for background-image (and a ::after gradient overlay for the dark backdrop) have no background-color on the container. WAVE can't load images or evaluate gradients, so it falls back to white. White text on white = 1:1.
Diagnosis: Hero sections with photos behind text flagged at 1:1 despite visually clear contrast.
Fix: Add a solid background-color fallback matching the darkest gradient value. This is invisible to visual users (the image covers it) but gives WAVE a computable dark color.
/* The gradient goes to rgba(4,14,25,.97) at the bottom */
.hero-a {
background-color: #040e19; /* fallback for contrast tools that can't read background-image */
/* rest of rules... */
}
.hero-b-left {
background-color: #040e19;
}
Note: hero-c uses background: #040f1c directly (no child image div), so it never had this issue.
3 — Low-opacity decorative glyphs (chevrons, watermarks)
Elements with opacity: 0.4 or similar applied via CSS/GSAP inherit a dimmed version of their parent's text color. WAVE computes this as the rendered color and flags it.
Common culprit: Dropdown chevrons (▾) inside nav links styled with opacity: .4.
Fix: aria-hidden="true" — these are purely decorative; the link text already conveys the interactive meaning.
<!-- BEFORE -->
<a href="#">Our Work <span class="snav-chev">▾</span></a>
<!-- AFTER -->
<a href="#">Our Work <span class="snav-chev" aria-hidden="true">▾</span></a>
Similarly for oversized watermark text (giant serif letters behind content):
<div class="tq-bg" aria-hidden="true">"</div>
<div class="co-type-bg" aria-hidden="true">Water</div>
4 — Form labels not programmatically linked
WAVE flags <label> elements that exist visually but are not connected to their input via for/id. These show as "Missing form label" errors, not contrast errors.
Fix: Add matching for and id attributes.
<!-- BEFORE -->
<label class="f-label">Email Address</label>
<input class="f-input" type="email" placeholder="alex@example.com">
<!-- AFTER -->
<label class="f-label" for="f-email">Email Address</label>
<input id="f-email" class="f-input" type="email" placeholder="alex@example.com">
False alarm vs. real failure — decision table
| WAVE symptom | Click element → it disappears? | Likely cause | Fix |
|---|---|---|---|
| 1:1 contrast on heading | Yes | GSAP opacity: 0 inline |
Switch to autoAlpha |
| 1:1 contrast on hero text | No | No background-color fallback on image container |
Add solid dark background-color |
| Low contrast on tiny glyph | N/A | Decorative icon with low opacity | aria-hidden="true" |
| Low contrast on label text | No | Real failure — muted color on light bg | Darken the color token |
| Missing form label | N/A | No for/id association |
Add for/id pair |
Limitations
- axe-core skips elements where background is a CSS gradient,
background-image, orfilter— use WAVE's eyedropper tool for those manually - WAVE counts each text node individually; axe deduplicates by color pair — expect axe to report fewer total violations than WAVE (e.g. 53 axe vs 187 WAVE)
- Hover and focus states are not evaluated by either tool — test manually
- Decorative elements (watermarks, icon chevrons, spacers) should receive
aria-hidden="true"rather than contrast fixes - WAVE evaluates all DOM elements regardless of scroll position — elements hidden by
scroll-triggered JS animations will be flagged unless using
autoAlphaorvisibility: hidden