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

// 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/'));

Steps

  1. Step 1.

    Start the demo server in its own terminal.

    node colour-demo.mjs
    
    http://localhost:8847/
  2. 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.mjs
    
    none            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.76

    Chrome 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.

  3. 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 text

    Both 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.

  4. 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 text

    Twenty-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.

  5. 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:1

    The none row 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

Sign: A screenshot under deuteranopia looks readable, so the colour-only defect is closed.Cause: The emulation answers a different question. Under achromatopsia the two status dots measured 1.11:1, which is a rendering both eyes can see and neither can tell apart. Readable and distinguishable are separate properties, and only the second one is what 1.4.1 asks about.
Sign: A pair passes under every emulated deficiency and still fails in the field.Cause: Chrome accepts achromatopsia, deuteranopia, protanopia and tritanopia, and rejects deuteranomaly. The anomalous trichromacies, which are the common forms, have no emulation here at all. A pass covers the four filters that ran and nothing else.
Sign: Sampling the rendered text pixel returns a colour the stylesheet never set.Cause: An earlier run of step 5 read the pixel at the left edge of the link text and got rgb(152,231,255) for #1a73e8. Antialiasing blends the glyph edge with the background, and subpixel rendering adds colour fringes. Painting the computed colour as a flat swatch removes both.
Sign: Green and red are swapped for blue and orange, and the table still cannot be read.Cause: Distance in hue is not distance in luminance. The demo dots, #2e7d32 and #c62828, sit at 1.10:1 with no filter applied. Any pair chosen for its hues alone can land there, which is why the fix is a second channel and not a different palette.

Thresholds

3:1 between the link text and the surrounding body text, plus a non-colour cue such as an underline on hover and on focus Source: WCAG 2.2 Technique G183, used to meet Success Criterion 1.4.1 Use of Color (w3.org/WAI/WCAG22/Techniques/general/G183)

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

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.

intermediate12 minpublished updated Maks Verny