How to check a website for color blindness
Emulate each deficiency with Chrome's Emulation.setEmulatedVisionDeficiency, then measure whether two colours that mean different things stay apart. On the demo page the passing and failing status dots sit at 1.10:1 with full colour vision and 1.01:1 under tritanopia, so hue is the only thing separating them.
Why check this
Run this when a screen starts using colour to say something: a status table, a diff view, a chart legend, a validation state, a required field. Run it again after a rebrand, because new hues rarely keep the old luminance gaps. The failure it prevents is a build dashboard where the failing service and the passing one look alike, and the on-call engineer restarts the wrong one.
State the boundary in the ticket. A simulation does not tell you what a person sees. What it decides is narrower and testable: whether a piece of information is carried by colour and by nothing else, which is what WCAG 1.4.1 Use of Color asks.
Prerequisites
- Node 22 and Chrome, plus
npm i puppeteer-core. The helper below opens one headless Chrome with its own profile. - The demo page, saved as
colour-demo.mjs. It plants a link without an underline, a link with one, a required field marked in red, and two status dots.
// colour-demo.mjs - four places where colour is the only carrier, one control
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Colour-only demo</title><style>
body{font:16px/1.6 system-ui;margin:2rem;max-width:36rem;color:#202124;background:#ffffff}
a.bare{color:#1a73e8;text-decoration:none} /* hue is the only difference from body text */
a.marked{color:#1a73e8;text-decoration:underline}/* the control: hue plus an underline */
label{display:block;margin:.4rem 0}
.required{color:#c62828} /* "required" said in red and nothing else */
.dot{display:inline-block;width:14px;height:14px;border-radius:50%;vertical-align:-2px}
#pass{background:#2e7d32} #fail{background:#c62828}
td{padding:.2rem .6rem}
</style></head><body>
<h1>Build status</h1>
<p id="copy">The nightly job writes its log to the share. Open the
<a class="bare" id="bare" href="/log">latest run</a> to see the failing step, or the
<a class="marked" id="marked" href="/archive">archive</a> for older runs.</p>
<form>
<label class="required" id="req">Email</label><input name="email">
<label id="opt">Nickname</label><input name="nick">
</form>
<table>
<tr><td><span class="dot" id="pass"></span></td><td>api-gateway</td></tr>
<tr><td><span class="dot" id="fail"></span></td><td>billing-worker</td></tr>
</table>
</body></html>`;
createServer((_, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(page);
}).listen(8847, () => console.log('http://localhost:8847/'));
- Every figure below is one capture on one machine, Chrome 152.0.7977.76. The filters are Chrome's own, and another browser will produce different numbers.
Steps
- Step 1.
Start the demo server in its own terminal.
node colour-demo.mjshttp://localhost:8847/ - Step 2.
Ask this Chrome build which deficiencies it will emulate, rather than trusting a list.
// cvd-types.mjs - which deficiencies does this Chrome build emulate? import { open } from '../../scripts/browser/session.mjs'; const s = await open(); try { await s.goto('about:blank'); for (const type of ['none', 'achromatopsia', 'deuteranopia', 'protanopia', 'tritanopia', 'blurredVision', 'reducedContrast', 'deuteranomaly', 'colourblind']) { try { await s.cdp.send('Emulation.setEmulatedVisionDeficiency', { type }); console.log(type.padEnd(16) + 'accepted'); } catch (e) { console.log(type.padEnd(16) + 'rejected: ' + e.message.split('\n')[0].replace(/^Protocol error.*?\): /, '')); } } console.log('\n' + await s.browser.version()); } finally { await s.close(); }node cvd-types.mjsnone accepted achromatopsia accepted deuteranopia accepted protanopia accepted tritanopia accepted blurredVision accepted reducedContrast accepted deuteranomaly rejected: Unknown vision deficiency type colourblind rejected: Unknown vision deficiency type Chrome/152.0.7977.76Chrome emulates the three dichromacies and total absence of colour. It does not emulate the anomalous forms, such as deuteranomaly, which are the more common ones.
- Step 3.
Find links that differ from the text around them by hue and nothing else.
// links.mjs - node links.mjs <url> - links inside a block of text that differ by hue alone import { open } from '../../scripts/browser/session.mjs'; const s = await open(); try { await s.goto(process.argv[2]); for (const line of await s.page.evaluate(() => { const rgb = (v) => (v.match(/[\d.]+/g) || []).map(Number); const lum = ([r, g, b]) => [r, g, b].map((v) => (v /= 255) <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) .reduce((a, c, i) => a + c * [0.2126, 0.7152, 0.0722][i], 0); const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); }; const out = []; let scanned = 0; for (const a of document.querySelectorAll('a')) { const p = a.parentElement; // G183 is about a link inside a block of text. A link that is the whole block is navigation. const around = p.textContent.trim().length - a.textContent.trim().length; if (around < 20) continue; scanned++; const c = getComputedStyle(a), pc = getComputedStyle(p); const cue = c.textDecorationLine !== 'none' ? 'underline' : c.fontWeight !== pc.fontWeight ? 'weight' : c.borderBottomStyle !== 'none' ? 'border' : c.backgroundImage !== 'none' ? 'background' : 'NONE'; const r = ratio(rgb(c.color), rgb(pc.color)); out.push(`#${(a.id || '-').padEnd(8)} link=${c.color.padEnd(18)} text=${pc.color.padEnd(18)}` + ` ratio=${r.toFixed(2)}:1 (needs 3.00) cue=${cue.padEnd(9)} -> ${cue === 'NONE' ? 'COLOUR ONLY' : 'ok'}`); } out.push(`${scanned} links inside a block of text`); return out; })) console.log(line); } finally { await s.close(); }node links.mjs http://localhost:8847/#bare link=rgb(26, 115, 232) text=rgb(32, 33, 36) ratio=3.57:1 (needs 3.00) cue=NONE -> COLOUR ONLY #marked link=rgb(26, 115, 232) text=rgb(32, 33, 36) ratio=3.57:1 (needs 3.00) cue=underline -> ok 2 links inside a block of textBoth links are the same blue at the same 3.57:1 against the body text, which clears the 3:1 half of the rule. Only the underline separates the pass from the failure.
- Step 4.
Run the same script against a page you do not control, and read the tail.
node links.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP | tail -6#- link=rgb(255, 255, 255) text=rgb(255, 255, 255) ratio=1.00:1 (needs 3.00) cue=NONE -> COLOUR ONLY #- link=rgb(255, 255, 255) text=rgb(255, 255, 255) ratio=1.00:1 (needs 3.00) cue=NONE -> COLOUR ONLY #- link=rgb(195, 199, 203) text=rgb(195, 199, 203) ratio=1.00:1 (needs 3.00) cue=underline -> ok #- link=rgb(195, 199, 203) text=rgb(195, 199, 203) ratio=1.00:1 (needs 3.00) cue=underline -> ok #- link=rgb(195, 199, 203) text=rgb(195, 199, 203) ratio=1.00:1 (needs 3.00) cue=underline -> ok 29 links inside a block of textTwenty-nine links qualified and two came back at 1.00:1 with no cue, which means the link colour equals the text colour. Those rows are for a person to look at, not a verdict: the script reads text decoration, weight, border and background image, and not a background colour or an icon.
- Step 5.
Paint every colour pair as a flat swatch, then sample it under each deficiency.
// cvd-pixels.mjs - node cvd-pixels.mjs <url> - do two colours stay apart once the hue is gone? import { open } from '../../scripts/browser/session.mjs'; // Each pair is a place where the page says something with colour. prop is what carries it. const PAIRS = [ ['link vs body text', '#bare', '#copy', 'color'], ['required vs optional', '#req', '#opt', 'color'], ['passing vs failing', '#pass', '#fail', 'backgroundColor'], ]; const TYPES = ['none', 'deuteranopia', 'protanopia', 'tritanopia', 'achromatopsia']; const s = await open(); try { await s.goto(process.argv[2]); // Read the page's own colours, then paint them as 20px swatches so the sample is a // flat colour and not an antialiased glyph edge. await s.page.evaluate((pairs) => { const strip = document.createElement('div'); strip.style.cssText = 'position:fixed;left:0;top:0;z-index:9999;display:flex'; for (const [, a, b, prop] of pairs) for (const sel of [a, b]) { const d = document.createElement('div'); d.style.cssText = `width:20px;height:20px;background:${getComputedStyle(document.querySelector(sel))[prop]}`; strip.append(d); } document.body.append(strip); }, PAIRS); const lum = ([r, g, b]) => [r, g, b].map((v) => (v /= 255) <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) .reduce((a, c, i) => a + c * [0.2126, 0.7152, 0.0722][i], 0); const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); }; const reader = await s.browser.newPage(); for (const type of TYPES) { await s.cdp.send('Emulation.setEmulatedVisionDeficiency', { type }); const shot = await s.page.screenshot({ encoding: 'base64', clip: { x: 0, y: 0, width: PAIRS.length * 40, height: 20 } }); const px = await reader.evaluate(async (data, n) => { const img = new Image(); img.src = 'data:image/png;base64,' + data; await img.decode(); const cv = Object.assign(document.createElement('canvas'), { width: img.width, height: img.height }); const g = cv.getContext('2d'); g.drawImage(img, 0, 0); return Array.from({ length: n }, (_, i) => [...g.getImageData(i * 20 + 10, 10, 1, 1).data].slice(0, 3)); }, shot, PAIRS.length * 2); console.log(type.padEnd(14) + PAIRS.map(([label], i) => { const [a, b] = [px[i * 2], px[i * 2 + 1]]; return `${label}: rgb(${a}) / rgb(${b}) ${ratio(a, b).toFixed(2)}:1`; }).join(' | ')); } } finally { await s.close(); }node cvd-pixels.mjs http://localhost:8847/none link vs body text: rgb(26,115,232) / rgb(32,33,36) 3.57:1 | required vs optional: rgb(198,40,40) / rgb(32,33,36) 2.86:1 | passing vs failing: rgb(46,125,50) / rgb(198,40,40) 1.10:1 deuteranopia link vs body text: rgb(0,110,230) / rgb(32,33,36) 3.35:1 | required vs optional: rgb(129,116,32) / rgb(32,33,36) 3.41:1 | passing vs failing: rgb(117,107,56) / rgb(129,116,32) 1.14:1 protanopia link vs body text: rgb(35,128,236) / rgb(32,33,36) 4.12:1 | required vs optional: rgb(91,82,38) / rgb(32,33,36) 2.05:1 | passing vs failing: rgb(127,114,42) / rgb(91,82,38) 1.62:1 tritanopia link vs body text: rgb(0,144,163) / rgb(31,34,34) 4.21:1 | required vs optional: rgb(218,0,43) / rgb(31,34,34) 3.07:1 | passing vs failing: rgb(30,122,109) / rgb(218,0,43) 1.01:1 achromatopsia link vs body text: rgb(119,119,119) / rgb(33,33,33) 3.60:1 | required vs optional: rgb(103,103,103) / rgb(33,33,33) 2.85:1 | passing vs failing: rgb(110,110,110) / rgb(103,103,103) 1.11:1The
nonerow is the self check:rgb(26,115,232)is the unfiltered#1a73e8, so the pipeline is reading what the page set. The status dots never rise above 1.62:1 in any row, including the unfiltered one.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A pair under 3:1 in the none row | The two states are close before any filter | Change one luminance, or add a shape, an icon or a word |
| A pair that stays under 3:1 in every row | Hue is the only carrier of that meaning | This fails 1.4.1. A filter cannot rescue it |
| A ratio that rises under a deficiency | The filter moved luminance, not just hue | Report the lowest row, not the average |
| cue=NONE with a ratio above 3:1 | The colour difference is fine, the second signal is missing | Add an underline, a weight change or an icon |
| ratio=1.00:1 with cue=NONE | The link colour equals the surrounding text | Open the page and look. Something else may be marking it |
Common mistakes
Thresholds
The 3:1 figure is the part most teams miss, because it is contrast between two pieces of text rather than between text and its background. The demo link clears it at 3.57:1 and still fails 1.4.1, since G183 asks for the ratio and the cue together. 1.4.1 itself carries no number.
What to check next
- How to check color contrast ratio: the same arithmetic, applied between text and its background instead of between two foregrounds.
- How to test form validation messages: a red border is the commonest colour-only state, and that page checks for the same information in words.
- How to check accessibility tree: a status dot with no accessible name is missing from the tree as well, and the tree is where the fix is confirmed.
- How to check visible focus indicator: a focus ring separated from the resting state by hue alone is this defect on a control.
- The colour contrast checker takes any two colours from the rows above.
FAQ
Is there a color blind website checker I can point at a URL?
Chrome is one, through the DevTools Rendering panel or the CDP call in step 2. It renders the page under four filters. It does not report which elements depend on colour, which is what step 5 measures.
How do I test a GUI for a color blind person?
Test the GUI, not the person. List every place the interface says something with colour, then measure whether each pair survives with the hue removed. A pair under 3:1 with no icon, shape or word beside it fails whichever deficiency you simulate.
Does passing the contrast ratio mean the page is safe for colour vision deficiency?
No. The two checks are independent. White text on a red button can pass 1.4.3 at 4.5:1 and still be the only marker that the button is destructive. The status dots hold no text, so a text contrast audit never sees them.
Which deficiency should I test first?
Deuteranopia and protanopia, because red and green are the colours interfaces use for pass and fail. Achromatopsia is the cheapest single test: a pair that survives with all colour removed survives the others.
Can a greyscale screenshot replace this?
For a first pass, yes. Achromatopsia in step 5 is a greyscale render, and it caught the status dots at 1.11:1. It misses pairs that keep a luminance gap and lose their hue difference, which the dichromatic rows report separately.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: color-contrast contrast ratio of two colours against WCAG AA and AAA for normal and large text
- Accessibility testing checklist
- All accessibility checks
intermediate12 minpublished updated Maks Verny