How to check color contrast ratio

Paste the text colour and the background colour into the checker below and read the ratio. Getting the background right is the work: getComputedStyle(el).backgroundColor returned rgba(0, 0, 0, 0) for eight of the nine elements on the demo page, and the ratio computed from that value is a measurement of black.

Why check this

Run this on every new colour a design system introduces, and again on staging after a theme change, because a token swap moves every surface at once. The failure it prevents is a ticket nobody can reproduce: body copy that reads fine on a bright laptop and vanishes on a dimmed screen.

The ratio itself is arithmetic. Two colours go in, WCAG relative luminance comes out, and the colour contrast checker above does it in your browser. What no tool decides for you is which two colours to hand it. That decision is the check.

Prerequisites

// contrast-demo.mjs  -  seven text/background pairs, five of them arguable
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Contrast demo</title><style>
 body{font:16px/1.5 system-ui;margin:0;color:#767676;background:#ffffff}
 section{padding:1.5rem}
 .card{background:#1f2937}
 .card p{color:#6b7280}             /* no background of its own: it inherits the card */
 h2{font-size:24px;color:#949494}   /* large text by the WCAG definition */
 .bold{font-size:19px;font-weight:700;color:#949494}
 .semi{font-size:19px;font-weight:600;color:#949494}
 .grad{background:linear-gradient(90deg,#ffffff 0%,#1f2937 100%);color:#111827}
 .shot{background:url(/bg.png) repeat;color:#ffffff}
 .veil{background:rgba(255,255,255,0.6)}
 button{background:#ffffff;border:1px solid #d1d5db;color:#1c1917;padding:.4rem .8rem;font:inherit}
</style></head><body>
<section><p id="body">Body copy at the default size.</p></section>
<section class="card"><p id="oncard">Text inside a dark card.</p></section>
<section><h2 id="heading">A heading at 24 pixels</h2>
 <p class="bold" id="bold">Bold at 19 pixels</p>
 <p class="semi" id="semibold">Semibold at 19 pixels</p></section>
<section class="grad"><p id="ongradient">Text over a gradient.</p></section>
<section class="shot"><p id="onimage">Text over an image.</p></section>
<section class="card"><div class="veil"><p id="onveil">Text on a translucent panel.</p></div></section>
<section><button id="btn">Continue</button></section>
</body></html>`;
// 4x4 mid-grey PNG, so the image background is a real bitmap and not a colour
const png = Buffer.from(
 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAF0lEQVR4nGP8//8/AzJgYkAD5AswMAAA5NIDATRZJ+cAAAAASUVORK5CYII=',
 'base64');
createServer((req, res) => {
  if (req.url === '/bg.png') { res.setHeader('content-type', 'image/png'); return res.end(png); }
  res.setHeader('content-type', 'text/html; charset=utf-8');
  res.end(page);
}).listen(8831, () => console.log('http://localhost:8831/'));

Steps

  1. Step 1.

    Start the demo server in its own terminal.

    node contrast-demo.mjs
    
    http://localhost:8831/
  2. Step 2.

    Take the reading most audit scripts take, straight from the element.

    // naive.mjs  -  node naive.mjs <url>  -  the reading most audits take
    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); };
        return [...document.querySelectorAll('p,h2,button')].map((el) => {
          const c = getComputedStyle(el);
          const fg = rgb(c.color), bg = rgb(c.backgroundColor);
          return `#${el.id.padEnd(11)} color=${c.color.padEnd(20)} backgroundColor=${c.backgroundColor.padEnd(20)} ratio=${ratio(fg, bg.slice(0, 3)).toFixed(2)}:1`;
        });
      })) console.log(line);
    } finally { await s.close(); }
    
    node naive.mjs http://localhost:8831/
    
    #body        color=rgb(118, 118, 118)   backgroundColor=rgba(0, 0, 0, 0)     ratio=4.62:1
    #oncard      color=rgb(107, 114, 128)   backgroundColor=rgba(0, 0, 0, 0)     ratio=4.34:1
    #heading     color=rgb(148, 148, 148)   backgroundColor=rgba(0, 0, 0, 0)     ratio=6.92:1
    #bold        color=rgb(148, 148, 148)   backgroundColor=rgba(0, 0, 0, 0)     ratio=6.92:1
    #semibold    color=rgb(148, 148, 148)   backgroundColor=rgba(0, 0, 0, 0)     ratio=6.92:1
    #ongradient  color=rgb(17, 24, 39)      backgroundColor=rgba(0, 0, 0, 0)     ratio=1.18:1
    #onimage     color=rgb(255, 255, 255)   backgroundColor=rgba(0, 0, 0, 0)     ratio=21.00:1
    #onveil      color=rgb(107, 114, 128)   backgroundColor=rgba(0, 0, 0, 0)     ratio=4.34:1
    #btn         color=rgb(28, 25, 23)      backgroundColor=rgb(255, 255, 255)   ratio=17.49:1

    Only the button has a background of its own. Every other row measured its colour against transparent black, so #onimage scored 21.00:1 on white text that sits on mid grey.

  3. Step 3.

    Walk up the ancestors to the first opaque background, compositing any translucent layer on the way.

    // effective.mjs  -  node effective.mjs <url>  -  contrast over the background that is really behind the text
    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); };
        // Walk up until a fully opaque background. Refuse when an image or a gradient is in the way.
        const behind = (el) => {
          const veils = [];
          for (let n = el; n; n = n.parentElement) {
            const c = getComputedStyle(n);
            if (c.backgroundImage !== 'none') return { refuse: `${n.tagName.toLowerCase()} paints ${c.backgroundImage.slice(0, 34)}…` };
            const [r, g, b, a = 1] = rgb(c.backgroundColor);
            if (a === 0) continue;
            if (a === 1) return { color: veils.reduceRight((base, v) => base.map((x, i) => Math.round(v.c[i] * v.a + x * (1 - v.a))), [r, g, b]), veils: veils.length };
            veils.push({ c: [r, g, b], a });
          }
          return { refuse: 'no opaque ancestor: the canvas colour is a user agent default' };
        };
        const out = [];
        let n = 0, bad = 0;
        for (const el of document.querySelectorAll('p,h2,button')) {
          if (!el.textContent.trim()) continue;
          n++;
          const tag = el.tagName.toLowerCase();
          const label = (el.id ? '#' + el.id : tag + '[' + n + ']').padEnd(11);
          const c = getComputedStyle(el);
          const px = parseFloat(c.fontSize), w = Number(c.fontWeight);
          const large = px >= 24 || (px >= 18.66 && w >= 700);
          const b = behind(el);
          if (b.refuse) { bad++; out.push(`${label} NO SINGLE BACKGROUND: ${b.refuse}`); continue; }
          const bg = `rgb(${b.color.join(', ')})`;
          const say = (what, fg, need, sc) => {
            const r = ratio(rgb(fg), b.color);
            if (r < need) bad++;
            out.push(`${label} ${what.padEnd(16)} fg=${fg.padEnd(20)} bg=${bg.padEnd(20)}`
              + `${b.veils ? `[${b.veils} alpha layer] ` : ''}${r.toFixed(2)}:1 needs ${need.toFixed(2)} SC ${sc} ${r >= need ? 'PASS' : 'FAIL'}`);
          };
          say(`${px}px/${w} ${large ? 'large' : 'normal'}`, c.color, large ? 3 : 4.5, '1.4.3');
          if (tag === 'button') say('border', c.borderTopColor, 3, '1.4.11');
        }
        out.push(`${n} elements read, ${bad} to look at`);
        return out;
      })) console.log(line);
    } finally { await s.close(); }
    
    node effective.mjs http://localhost:8831/
    
    #body       16px/400 normal  fg=rgb(118, 118, 118)   bg=rgb(255, 255, 255)  4.54:1 needs 4.50 SC 1.4.3 PASS
    #oncard     16px/400 normal  fg=rgb(107, 114, 128)   bg=rgb(31, 41, 55)     3.04:1 needs 4.50 SC 1.4.3 FAIL
    #heading    24px/700 large   fg=rgb(148, 148, 148)   bg=rgb(255, 255, 255)  3.03:1 needs 3.00 SC 1.4.3 PASS
    #bold       19px/700 large   fg=rgb(148, 148, 148)   bg=rgb(255, 255, 255)  3.03:1 needs 3.00 SC 1.4.3 PASS
    #semibold   19px/600 normal  fg=rgb(148, 148, 148)   bg=rgb(255, 255, 255)  3.03:1 needs 4.50 SC 1.4.3 FAIL
    #ongradient NO SINGLE BACKGROUND: section paints linear-gradient(90deg, rgb(255, 25…
    #onimage    NO SINGLE BACKGROUND: section paints url("http://localhost:8831/bg.png"…
    #onveil     16px/400 normal  fg=rgb(107, 114, 128)   bg=rgb(165, 169, 175)  [1 alpha layer] 2.05:1 needs 4.50 SC 1.4.3 FAIL
    #btn        16px/400 normal  fg=rgb(28, 25, 23)      bg=rgb(255, 255, 255)  17.49:1 needs 4.50 SC 1.4.3 PASS
    #btn        border           fg=rgb(209, 213, 219)   bg=rgb(255, 255, 255)  1.47:1 needs 3.00 SC 1.4.11 FAIL
    9 elements read, 6 to look at

    #oncard flipped from 4.34:1 pass to 3.04:1 fail. #bold and #semibold are the same grey at the same 19 px and get opposite verdicts, because one is weight 700 and the other is 600.

  4. Step 4.

    Ask Chrome for the same backgrounds, through the call the DevTools contrast pane uses.

    // devtools-bg.mjs  -  node devtools-bg.mjs <url>  -  what the DevTools contrast pane sees
    import { open } from '../../scripts/browser/session.mjs';
    const s = await open();
    try {
      await s.goto(process.argv[2]);
      await s.cdp.send('DOM.enable');
      await s.cdp.send('CSS.enable');
      const { root } = await s.cdp.send('DOM.getDocument', { depth: -1 });
      const { nodeIds } = await s.cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: 'p,h2,button' });
      for (const nodeId of nodeIds) {
        const { attributes } = await s.cdp.send('DOM.getAttributes', { nodeId });
        const id = attributes[attributes.indexOf('id') + 1];
        const r = await s.cdp.send('CSS.getBackgroundColors', { nodeId });
        console.log(`#${id.padEnd(11)} backgroundColors=${JSON.stringify(r.backgroundColors ?? null).padEnd(46)}`
          + ` fontSize=${(r.computedFontSize ?? '-').padEnd(6)} fontWeight=${r.computedFontWeight ?? '-'}`);
      }
    } finally { await s.close(); }
    
    node devtools-bg.mjs http://localhost:8831/
    
    #body        backgroundColors=["rgb(255, 255, 255)"]                         fontSize=16px   fontWeight=400
    #oncard      backgroundColors=["rgb(31, 41, 55)"]                            fontSize=16px   fontWeight=400
    #heading     backgroundColors=["rgb(255, 255, 255)"]                         fontSize=24px   fontWeight=700
    #bold        backgroundColors=["rgb(255, 255, 255)"]                         fontSize=19px   fontWeight=700
    #semibold    backgroundColors=["rgb(255, 255, 255)"]                         fontSize=19px   fontWeight=600
    #ongradient  backgroundColors=["rgb(255, 255, 255)","rgb(31, 41, 55)"]       fontSize=16px   fontWeight=400
    #onimage     backgroundColors=null                                           fontSize=16px   fontWeight=400
    #onveil      backgroundColors=["rgb(165, 169, 175)"]                         fontSize=16px   fontWeight=400
    #btn         backgroundColors=["rgb(255, 255, 255)"]                         fontSize=16px   fontWeight=400

    Two independent implementations landed on the same composite, rgb(165, 169, 175). Chrome returns an array, not a colour: two entries for the gradient and null for the bitmap. That is why DevTools shows no ratio on those nodes rather than a wrong one.

  5. Step 5.

    Point the audit at a page you do not control, and print only the rows that need a human.

    node effective.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP | grep -E "FAIL|NO SINGLE|elements read"
    
    button[1]   border           fg=rgba(0, 0, 0, 0)     bg=rgb(24, 25, 27)     1.19:1 needs 3.00 SC 1.4.11 FAIL
    …
    button[15]  border           fg=rgba(0, 0, 0, 0)     bg=rgb(24, 25, 27)     1.19:1 needs 3.00 SC 1.4.11 FAIL
    69 elements read, 8 to look at

    Sixty-nine elements, no text failure, and eight identical rows against a dark background nobody asked for.

  6. Step 6.

    Find out what those eight rows are before filing them.

    // why-null.mjs  -  node why-null.mjs <url>  -  is that border drawn, and which theme did Chrome ask for?
    import { open } from '../../scripts/browser/session.mjs';
    const s = await open();
    try {
      await s.goto(process.argv[2]);
      const read = () => s.page.evaluate(() => {
        const scheme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        const html = getComputedStyle(document.documentElement).backgroundColor;
        let n = 0;
        const rows = [];
        for (const el of document.querySelectorAll('p,h2,button')) {
          if (!el.textContent.trim()) continue;
          n++;
          if (el.tagName !== 'BUTTON' || rows.length >= 2) continue;
          const c = getComputedStyle(el);
          rows.push(`button[${n}] border-style=${c.borderTopStyle} border-width=${c.borderTopWidth} border-color=${c.borderTopColor}`);
        }
        return `scheme=${scheme} html-bg=${html}\n  ` + rows.join('\n  ');
      });
      console.log('default   : ' + await read());
      await s.cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] });
      console.log('light     : ' + await read());
    } finally { await s.close(); }
    
    node why-null.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    default   : scheme=dark html-bg=rgb(24, 25, 27)
    button[1] border-style=solid border-width=1px border-color=rgba(0, 0, 0, 0)
    button[3] border-style=solid border-width=1px border-color=rgba(0, 0, 0, 0)
    light     : scheme=light html-bg=rgb(255, 255, 255)
    button[1] border-style=solid border-width=1px border-color=rgba(0, 0, 0, 0)
    button[3] border-style=solid border-width=1px border-color=rgba(0, 0, 0, 0)

    The border is a 1 px spacer with a transparent colour, so 1.4.11 has nothing to measure. The second line matters more: this headless Chrome asked for the dark theme, so step 5 describes a theme the audit never chose.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | backgroundColor: rgba(0, 0, 0, 0) | The element paints nothing; an ancestor does | Walk up until alpha is 1, then measure | | [1 alpha layer] in the row | A translucent panel sits between text and surface | Compare the composite against the layer colour before changing either | | NO SINGLE BACKGROUND | A gradient or a bitmap is behind the text | Measure the worst pixel by hand, or put an opaque plate behind the text | | needs 3.00 SC 1.4.3 | The text qualified as large | Confirm the size and the weight, not only the size | | needs 3.00 SC 1.4.11 on a border | A component boundary was measured | Check border-style and alpha first; a transparent border is not a boundary |

Common mistakes

Sign: An audit reports a comfortable ratio for text that is hard to read on screen.Cause: getComputedStyle returns rgba(0, 0, 0, 0) for any element without its own background, and a parser that keeps only the first three channels reads that as black. Eight of the nine demo elements returned it. Light text then scores against black and passes, and #onimage reached 21.00:1 over a mid grey bitmap.
Sign: Two runs of the same audit on the same URL disagree about every background.Cause: Headless Chrome asked for prefers-color-scheme: dark on this machine, so the MDN run measured the dark theme and reported html-bg rgb(24, 25, 27). Forcing the media feature switched it to rgb(255, 255, 255). Name the scheme in the report, or set it with Emulation.setEmulatedMedia before reading.
Sign: A 19 px heading passes on one page and fails on another with identical colours.Cause: WCAG large scale text is at least 18 point, or 14 point when bold, which browsers render as 24 px and 18.66 px. 19 px at weight 700 clears the bold branch and needs 3:1; the same 19 px at weight 600 does not and needs 4.5:1. Both rows read 3.03:1 and the verdicts differ.
Sign: A component is reported as failing 1.4.11 because its border is low contrast.Cause: 1.4.11 asks for 3:1 on what identifies the component, not on any border property. A 1 px border with border-color rgba(0, 0, 0, 0) draws nothing, and eight rows on the MDN run were exactly that. Read border-style and the alpha channel before the ratio.

Thresholds

4.5:1 for normal text and 3:1 for large text Source: WCAG 2.2 Success Criterion 1.4.3 Contrast (Minimum), Level AA (w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
3:1 between a user interface component and its adjacent colour Source: WCAG 2.2 Success Criterion 1.4.11 Non-text Contrast, Level AA (w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)

Large scale is defined in points, not pixels: at least 18 point, or 14 point and bold. At the usual 96 dpi mapping that is 24 px, and 18.66 px when bold. WCAG puts no number on "bold", so a design system that ships weight 600 as its bold sits outside the definition. The script above treats 700 and above as bold.

What to check next

FAQ

How do I check color contrast for accessibility on a whole site?

Run the step 3 script over each template rather than each URL, and group by computed colour pair. One token usually produces every failing row, so a site audit collapses to a handful of pairs. Rows printing NO SINGLE BACKGROUND need a person.

What contrast ratio do I need to pass?

4.5:1 for normal text, 3:1 for large text, and 3:1 for the parts of a control that identify it, all at Level AA. Level AAA raises the text figures to 7:1 and 4.5:1. The checker at the top of this page reports all four at once.

How do I test color contrast over an image or a gradient?

There is no single value, which is why Chrome returns null for the bitmap and two colours for the gradient. Measure the lightest and darkest pixel the text crosses and take the worse result, or put an opaque plate behind the text.

Is a 4.5:1 pass enough on its own?

No. The ratio ignores weight, letter spacing and the way antialiasing thins light text on dark surfaces. #body clears the bar at 4.54:1 and is still grey on white. Treat the number as the floor.

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.

intermediate8 minpublished updated Maks Verny