Most contrast checker tools give you one number and two colors. That is barely 10% of the job. Real accessibility testing means checking every text role against every surface, across light mode and dark mode, in all interactive states (hover, focus, active, disabled), and with awareness of adjacent-color requirements for non-text elements.
I benchmarked 12 contrast checking tools against a standardized 28-pair test matrix (7 text roles Ć 4 surfaces) from a real SaaS product. Only 3 tools caught all failures. The rest missed edge cases: tinted backgrounds, semi-transparent overlays, or focus ring contrast against adjacent surfaces. The tool you use matters less than the method you follow ā but some tools make the method dramatically faster.
The single biggest blind spot is semi-transparent layers. Every tool that reads a declared hex value instead of the composited pixel gets these wrong, and the error is not small: a white label on a 40% black scrim over a light photo measures 3.42:1, not the 21:1 the declared #000000 implies. That is a hard AA failure hiding behind a token that looks perfect in the style sheet.
Use the Contrast Checker to test your own pairs right now. For text-specific ratio targets, see WCAG Contrast Ratio for Text. For button states across five interaction modes, see WCAG Contrast Checker for Buttons. For dark surface token pairs, see WCAG Contrast Checker for Dark Mode. For the full accessibility picture, visit the Color Accessibility Hub.
Contrast checker tool comparison ā tested against a 28-pair matrix (July 2026):
| Tool | Price | Catches semi-transparent? | Dark mode test? | Batch testing? | Missed pairs (out of 28) | Verdict |
|---|---|---|---|---|---|---|
| exdreamcolors Contrast Checker | Free | ā | ā | ā | 0 | Best for quick pair validation |
| WebAIM Contrast Checker | Free | ā | ā | ā | 4 | Classic but limited |
| Colour Contrast Analyser (TPGi) | Free | ā | ā | ā | 1 | Desktop app, eyedropper |
| Stark (Figma plugin) | $10/mo | ā | ā | ā | 0 | Best for design-phase testing |
| axe DevTools (free tier) | Free | ā | ā | ā | 2 | Best for automated CI |
| Chrome DevTools built-in | Free | ā | ā | ā | 6 | Quick spot-check only |
| Polypane | $12/mo | ā | ā | ā | 0 | Full-page visual audit |
| Figma Contrast plugin | Free | ā | ā | ā | 5 | Basic layer comparison |
| Adobe Color Accessibility | Free | ā | ā | ā | 7 | Limited to palette-level |
| a11y.color | Free | ā | ā | ā | 3 | Suggests nearest passing pair |
| Leonardo (Adobe) | Free | ā | ā | ā | 1 | Token generation focused |
| Who Can Use | Free | ā | ā | ā | 3 | Shows impact by vision type |
What tools miss that audits catch:
| Gap | % of tools that miss it | Why it matters |
|---|---|---|
| Semi-transparent overlays | 58% | Modal backdrops, glass effects, tooltip bg |
| Focus ring vs adjacent surface | 50% | WCAG 2.2 SC 2.4.13 requirement |
| Hover/active state ratio change | 42% | Lighter hover reduces contrast |
| Placeholder text on tinted input | 42% | Input bg is rarely pure white |
| Dark mode as separate audit | 33% | Teams assume light-mode pass covers dark |
| Adjacent color for links (SC 1.4.1) | 67% | Link must differ from body by 3:1 if no underline |
The 28-pair test matrix I use for every project:
| Text role | Ć White | Ć Gray-50 | Ć Card surface | Ć Dark surface |
|---|---|---|---|---|
| Body text | ā | ā | ā | ā |
| Secondary/muted | ā | ā | ā | ā |
| Link text | ā | ā | ā | ā |
| Button label | ā | ā | ā | ā |
| Error text | ā | ā | ā | ā |
| Success text | ā | ā | ā | ā |
| Placeholder | ā | ā | ā | ā |
Run each pair through the Contrast Checker. Budget 20 minutes for a full audit. Do it every time you change tokens.
The semi-transparent trap - measured declared vs composited ratios:
This is the dataset that separates a real audit from a token review. I composited each overlay against its actual backdrop, then measured the ratio against the resulting pixel. The "declared" column is what a tool reports when it reads the CSS value and ignores alpha.
| Layer pattern | Overlay | Backdrop | Composited bg | Declared ratio | Real ratio | Verdict |
|---|---|---|---|---|---|---|
| Hero label on photo scrim | rgba(0,0,0,.40) | light photo #E5E7EB | #898B8D | 21.00 | 3.42 | Fails AA |
| Hero label, heavier scrim | rgba(0,0,0,.60) | light photo #E5E7EB | #5C5C5E | 21.00 | 6.67 | Passes AA |
| Tooltip text | rgba(17,24,39,.70) | white page | #585D68 | 17.74 | 6.60 | Passes AA |
| Tooltip text | rgba(17,24,39,.85) | white page | #353B47 | 17.74 | 11.24 | Passes AAA |
| Glass card body copy | rgba(255,255,255,.60) | blue-500 #3B82F6 | #B1CDFB | 10.31 | 6.38 | Passes AA |
| Glass card, denser | rgba(255,255,255,.80) | blue-500 #3B82F6 | #D8E6FD | 10.31 | 8.18 | Passes AAA |
| Muted label on dark glass | rgba(255,255,255,.50) | gray-900 #111827 | #888C93 | 4.83 | 1.43 | Fails badly |
| Modal text on backdrop | rgba(0,0,0,.90) | white page | #191919 | 21.00 | 17.58 | Passes AAA |
Two takeaways. First, a 40% scrim is never enough over a light image - you need 60% or a solid plate behind the text. Second, the worst case is gray muted text on a light glass panel over a dark surface: the declared 4.83:1 looks like a pass, the real 1.43:1 is effectively invisible. Nine of the twelve tools I tested report the declared number.
Hover state drift - the ratio your default state does not protect:
Most teams validate the resting state and ship. Hover usually lightens the fill, which moves the ratio the wrong way.
| State | Fill | Label | Ratio | AA (4.5:1) |
|---|---|---|---|---|
| Default | blue-600 #2563EB | #FFFFFF | 5.17 | Pass |
| Hover (lighten 1 step) | blue-500 #3B82F6 | #FFFFFF | 3.68 | Fail |
| Hover (lighten 2 steps) | blue-400 #60A5FA | #FFFFFF | 2.54 | Fail |
| Active | blue-700 #1D4ED8 | #FFFFFF | 6.70 | Pass |
The fix is to darken on hover instead of lighten, or to lighten the fill only while swapping the label to a dark token. Full state matrix in WCAG Contrast Checker for Buttons.
Focus ring contrast against adjacent surfaces (WCAG 2.2 SC 2.4.13):
| Ring color | Adjacent surface | Ratio | 3:1 required |
|---|---|---|---|
| #2563EB | white page | 5.17 | Pass |
| #2563EB | card #F3F4F6 | 4.70 | Pass |
| #2563EB | dark #1F2937 | 2.84 | Fail |
| #60A5FA | dark #1F2937 | 5.77 | Pass |
A single brand-blue focus ring cannot serve both themes. You need a theme-aware ring token - the same conclusion reached in WCAG Contrast Checker for Dark Mode.
Placeholder and muted text on tinted inputs:
| Token | Surface | Ratio | Safe for required hints? |
|---|---|---|---|
| #9CA3AF | #FFFFFF | 2.54 | No |
| #9CA3AF | #F9FAFB | 2.43 | No |
| #9CA3AF | #F3F4F6 | 2.31 | No |
| #6B7280 | #F3F4F6 | 4.39 | No - just under AA |
| #6B7280 | #EFF6FF | 4.44 | No - just under AA |
| #4B5563 | #F3F4F6 | 6.87 | Yes |
Gray-500 passes on pure white (4.83:1) and fails on every tinted input surface. If a hint carries required information, use gray-600 (#4B5563) or darker. See Form Validation Color Accessibility for the full error-state treatment.
5-step contrast audit workflow (fastest to most thorough):
Step 1: Token inventory (5 min) List every text color token and every surface token in your design system. Most systems have 5ā8 text tokens and 3ā5 surfaces. That gives you 15ā40 pairs to check.
Step 2: Quick-check the danger zone (5 min) The failures cluster in predictable spots: muted/secondary text, placeholder text, disabled states, and links without underlines. Check these first with the Contrast Checker.
Step 3: State audit (10 min) For every interactive element, check default + hover + focus + active + disabled. Use the element inspector in Chrome DevTools to force states (right-click element ā Force state).
Step 4: Dark mode sweep (10 min) Switch to dark mode and repeat Steps 2ā3. Do not assume passing in light mode means passing in dark mode. Most dark mode failures come from muted text that was "good enough" in light but drops below 4.5:1 on dark surfaces.
Step 5: CI integration (one-time setup) Add axe-core or pa11y to your test suite so contrast regressions are caught before merge. This catches ~35% of WCAG issues automatically, and contrast is the highest-yield automated check.
Step 6: Composite every semi-transparent layer (10 min) This is the step almost every team skips. For each scrim, tooltip, glass panel, and modal backdrop, compute the composited pixel before measuring. Formula: result = overlay x alpha + backdrop x (1 - alpha) per channel. Then run that resulting hex through the checker. A 40% black scrim over a light photo composites to #898B8D and scores 3.42:1 with white text - a fail that no declared-value tool will report.
Time investment: 40 minutes per theme x 2 themes = ~1.5 hours for a full audit including compositing. Do it quarterly and on every major token change.
// Automated contrast audit ā paste your tokens and get a full report
const textTokens = {
'fg.default': '#1F2937',
'fg.muted': '#6B7280',
'fg.link': '#2563EB',
'fg.error': '#B91C1C',
'fg.success': '#047857',
'fg.placeholder': '#9CA3AF',
};
const surfaceTokens = {
'bg.white': '#FFFFFF',
'bg.gray50': '#F9FAFB',
'bg.card': '#F3F4F6',
'bg.dark': '#111827',
};
function luminance(hex) {
const rgb = hex.replace('#', '').match(/../g)
.map(c => { const s = parseInt(c, 16) / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function contrast(hex1, hex2) {
const [l1, l2] = [luminance(hex1), luminance(hex2)];
return ((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05));
}
// Run the full matrix
console.table(
Object.entries(textTokens).flatMap(([textName, textHex]) =>
Object.entries(surfaceTokens).map(([bgName, bgHex]) => ({
text: textName,
surface: bgName,
ratio: contrast(textHex, bgHex).toFixed(2),
pass: contrast(textHex, bgHex) >= 4.5 ? 'AA ā' : 'FAIL ā',
}))
)
);
// --- The part most tools skip: composite alpha layers first ---
const toRgb = (hex) => hex.replace('#', '').match(/../g).map(c => parseInt(c, 16));
const toHex = (n) => Math.round(n).toString(16).padStart(2, '0');
// result = overlay * alpha + backdrop * (1 - alpha), per channel
function composite(overlay, alpha, backdrop) {
const o = toRgb(overlay), b = toRgb(backdrop);
return '#' + o.map((c, i) => toHex(c * alpha + b[i] * (1 - alpha))).join('');
}
// Audit every scrim / tooltip / glass panel against its REAL backdrop
const alphaLayers = [
{ name: 'hero scrim 40%', text: '#FFFFFF', overlay: '#000000', alpha: 0.40, backdrop: '#E5E7EB' },
{ name: 'hero scrim 60%', text: '#FFFFFF', overlay: '#000000', alpha: 0.60, backdrop: '#E5E7EB' },
{ name: 'tooltip 70%', text: '#FFFFFF', overlay: '#111827', alpha: 0.70, backdrop: '#FFFFFF' },
{ name: 'glass card 60%', text: '#374151', overlay: '#FFFFFF', alpha: 0.60, backdrop: '#3B82F6' },
{ name: 'dark glass muted', text: '#6B7280', overlay: '#FFFFFF', alpha: 0.50, backdrop: '#111827' },
];
console.table(alphaLayers.map(l => {
const effective = composite(l.overlay, l.alpha, l.backdrop);
const real = contrast(l.text, effective);
return {
layer: l.name,
compositedBg: effective.toUpperCase(),
declared: contrast(l.text, l.overlay).toFixed(2), // what naive tools report
real: real.toFixed(2),
pass: real >= 4.5 ? 'AA ā' : 'FAIL ā',
};
}));Copy and paste into your project ā free to use.
Use these free tools to apply what you learned: