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
- Node 22 and Chrome on one machine, plus
npm i puppeteer-core, if you want the ratio over a real page instead of two hex values. - The WCAG 2.2 definition of contrast ratio and the relative luminance formula it uses.
- The demo page below, saved as
contrast-demo.mjs. Nine elements: opaque backgrounds, an inherited one, a gradient, a bitmap, a translucent panel, and a button whose border is the point.
// 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/'));
- Every figure below is one capture on one machine, Chrome 152.0.7977.76. Computed colours move with the theme the browser asks for, which step 6 shows.
Steps
- Step 1.
Start the demo server in its own terminal.
node contrast-demo.mjshttp://localhost:8831/ - 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:1Only the button has a background of its own. Every other row measured its colour against transparent black, so
#onimagescored 21.00:1 on white text that sits on mid grey. - 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#oncardflipped from 4.34:1 pass to 3.04:1 fail.#boldand#semiboldare the same grey at the same 19 px and get opposite verdicts, because one is weight 700 and the other is 600. - 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=400Two independent implementations landed on the same composite,
rgb(165, 169, 175). Chrome returns an array, not a colour: two entries for the gradient andnullfor the bitmap. That is why DevTools shows no ratio on those nodes rather than a wrong one. - 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 atSixty-nine elements, no text failure, and eight identical rows against a dark background nobody asked for.
- 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/HTTPdefault : 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
Thresholds
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
- How to check a website for color blindness: a passing ratio says nothing about whether colour alone carries the meaning.
- How to check visible focus indicator: the focus ring is the component 1.4.11 is most often measured on, and that page carries its size and contrast figures.
- How to check alt text on images: text baked into a bitmap has no computed colour, so every audit above misses it.
- How to test a page at 200 percent zoom: zoom leaves the CSS pixel size alone, so a 19 px heading stays normal text.
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.
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
intermediate8 minpublished updated Maks Verny