CoreMesh

Color Contrast Accessibility Checker: WCAG Ratios and a Script

What WCAG AA and AAA require for color contrast, how the ratio is computed, and a runnable Node.js checker you can point at your own palette.

A color contrast accessibility checker takes two colors, computes their contrast ratio, and tells you whether the pair meets WCAG. For AA, body text needs at least 4.5:1, large text needs 3:1, and UI components and graphical objects like icons need 3:1. AAA raises body text to 7:1 and large text to 4.5:1.

You don't need a service for this. The math is short, and the script further down checks a whole palette in one run. First, the requirements, then the formula, then the tooling and the mistakes that bite people.

Color contrast requirements: AA and AAA#

These are the numbers from the WCAG guidance as summarized on MDN:

Type of content AA minimum AAA enhanced
Body text 4.5 : 1 7 : 1
Large-scale text 3 : 1 4.5 : 1
Active UI components and graphical objects (icons, graphs) 3 : 1 Not defined

The three success criteria behind the table:

  • 1.4.3 Contrast (Minimum), AA: text needs 4.5:1, or 3:1 if it's large.
  • 1.4.6 Contrast (Enhanced), AAA: text needs 7:1, or 4.5:1 if it's large.
  • 1.4.11 Non-text Contrast, AA: UI components and graphical objects need 3:1.

My advice: treat AA as the floor and aim higher for body copy where your design allows it. MDN says the same thing in different words: make contrast as good as it can be within your constraints, ideally AAA, and at least AA.

What counts as large text#

Large text is at least 18pt, or at least 14pt bold. The W3C notes that 1pt is 1.333px, so 14pt and 18pt come out to roughly 18.5px and 24px. In CSS terms, a bold 18.5px label qualifies for the lower ratio, but regular 18px body text does not.

The W3C also warns that "bold" and "18 point" can mean different things in different fonts. Very thin or unusual fonts can render fainter than the CSS color suggests because of anti-aliasing. A pair that nominally passes can still read poorly, so for thin typefaces I'd go well past the minimum.

How the contrast ratio is computed#

The ratio compares the relative luminance of two colors. The W3C describes the formula as L1/L2, with 0.05 added to each side to account for typical viewing flare. The lighter color is L1 and the darker is L2:

text
ratio = (L_lighter + 0.05) / (L_darker + 0.05)

Relative luminance comes from the sRGB channels. The conversion from nonlinear to linear values is based on the IEC sRGB specification. Each 8-bit channel is divided by 255, linearized, then weighted by how strongly the eye responds to red, green and blue. The weights below are the standard WCAG definition, which the WCAG 2.2 text spells out.

text
c = channel / 255
linear = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
L = 0.2126 * R + 0.7152 * G + 0.0722 * B

Because the formula only uses luminance, hue plays almost no part. The W3C intent notes say this is deliberate: people with color vision deficiency mostly differ in hue perception, so light-dark contrast is the measure that works regardless of the specific deficiency.

A runnable color contrast accessibility checker#

Here's the smallest version that works. It uses the two background colors from the MDN example, #5a80a9 and #400064, with the default black text from that page, plus white as a comparison.

javascript
function channel(c) {
  c /= 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

function luminance(hex) {
  let h = hex.replace('#', '');
  if (h.length === 3) h = [...h].map(x => x + x).join('');
  const n = parseInt(h, 16);
  const r = (n >> 16) & 255;
  const g = (n >> 8) & 255;
  const b = n & 255;
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}

function ratio(a, b) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

const pairs = [
  ['#000000', '#5a80a9'],
  ['#000000', '#400064'],
  ['#ffffff', '#400064'],
];

for (const [fg, bg] of pairs) {
  const r = ratio(fg, bg);
  console.log(fg, 'on', bg, '->', r.toFixed(2) + ':1',
    'AA text:', r >= 4.5, 'AAA text:', r >= 7);
}

Note that the comparison uses the raw ratio, not the rounded one. The toFixed(2) is only for display. That matters, as the next section explains.

Check a whole palette against the right threshold#

In practice you have design tokens, and each pair has a different job. Body text should be held to 4.5:1, an icon to 3:1. This version takes a list of pairs with a role and picks the threshold for you. The color values are made-up examples; swap in your own.

javascript
function channel(c) {
  c /= 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function luminance(hex) {
  let h = hex.replace('#', '');
  if (h.length === 3) h = [...h].map(x => x + x).join('');
  const n = parseInt(h, 16);
  return 0.2126 * channel((n >> 16) & 255) +
         0.7152 * channel((n >> 8) & 255) +
         0.0722 * channel(n & 255);
}
function ratio(a, b) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

const NEEDS = {
  body:  { AA: 4.5, AAA: 7 },
  large: { AA: 3,   AAA: 4.5 },
  ui:    { AA: 3,   AAA: null }, // AAA is not defined for UI components
};

const palette = [
  { name: 'body copy',     role: 'body',  fg: '#4a4a4a', bg: '#ffffff' },
  { name: 'muted caption', role: 'body',  fg: '#9a9a9a', bg: '#ffffff' },
  { name: 'hero heading',  role: 'large', fg: '#ffffff', bg: '#5a80a9' },
  { name: 'input border',  role: 'ui',    fg: '#8a8a8a', bg: '#ffffff' },
];

for (const p of palette) {
  const r = ratio(p.fg, p.bg);
  const need = NEEDS[p.role];
  const aa = r >= need.AA ? 'pass' : 'FAIL';
  const aaa = need.AAA === null ? 'n/a' : (r >= need.AAA ? 'pass' : 'fail');
  console.log(p.name.padEnd(14), r.toFixed(2).padStart(6) + ':1',
    'AA', aa, '| AAA', aaa);
}

The null for UI AAA is deliberate. The MDN table lists it as "Not defined", so the script reports n/a instead of inventing a number.

How to check color contrast in your tools#

The source pack points to two places to check without writing code:

  • WebAIM's Color Contrast Checker: paste two colors, get a ratio. MDN recommends it for checking choices as you make them.
  • Firefox DevTools: the Accessibility inspector has a "Check for accessibility issues" section, so you can check contrast on the live page.

The W3C's related-resources list also names the Colour Contrast Analyser application and a few other analyzers.

The docs I used don't cover Chrome, Figma, PowerPoint or PDF tooling, so I won't describe their menus. The rule is the same everywhere, though: get the actual foreground and background colors out of the tool as hex values, then run them through the checker above or WebAIM's. The ratio doesn't depend on where the colors came from.

Common mistakes and gotchas#

Rounding up. The W3C says 3:1 and 4.5:1 are thresholds and computed values should not be rounded. A 4.499:1 pair fails 4.5:1. Many checkers display two decimals, so a pair showing 4.50 might technically fall short. Compare raw values, as the scripts above do.

Assuming brand colors are exempt. Only logos and logotypes are exempt. The W3C states that text with insufficient contrast due to corporate identity or brand guidelines is not exempted beyond that. Even a logo that acts as a link or control is better served by a higher-contrast variant, according to the same page.

Forgetting states and placeholders. The criterion applies to placeholder text, and to text shown on hover or keyboard focus. A form with a pale placeholder fails even if every label passes.

Low-contrast-until-hover designs. If a design shows logos or text faintly and only strengthens them on hover, that initial presentation still has to pass. The W3C gives this exact case as a failure.

Skipping non-text elements. Chart lines, icons and input borders fall under 1.4.11 at 3:1. Text-like symbols count too: the W3C notes that an X used as a close button, or a > used as an arrow, is treated as non-text content and measured against 3:1.

Trusting the screen over the CSS. Anti-aliasing and font smoothing are outside your control. The W3C says to evaluate the foreground and background colors from the markup and stylesheets, not from how the text looks on a particular screen.

Treating inactive controls as failures. The ratios don't apply to incidental text such as inactive controls, logotypes, or purely decorative text. Don't burn time recoloring a disabled button just to hit 4.5:1, but do make sure it isn't carrying information that's needed elsewhere.

Fixing low contrast colors#

Low contrast colors are pairs where the light-dark difference is too small, so edges and details blur together. People with low vision, and some types of color blindness, have trouble seeing edges and borders when that difference is small. The MDN example makes the point: black text on #400064, a very dark purple, is much harder to read than black on #5a80a9.

When a pair fails, the fix is almost always to change lightness, not hue. This script nudges a foreground color toward black or white in small steps until it reaches a target, so you can see how far you have to move.

javascript
function channel(c) {
  c /= 255;
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function lum([r, g, b]) {
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function ratio(a, b) {
  const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}
const toHex = rgb => '#' + rgb.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');

function fix(fg, bg, target) {
  // move toward black if the background is light, otherwise toward white
  const goal = lum(bg) > 0.5 ? 0 : 255;
  for (let t = 0; t <= 1.0001; t += 0.01) {
    const c = fg.map(v => v + (goal - v) * t);
    if (ratio(c, bg) >= target) return { hex: toHex(c), ratio: ratio(c, bg) };
  }
  return null;
}

const muted = [154, 154, 154]; // #9a9a9a
const white = [255, 255, 255];

for (const target of [4.5, 7]) {
  const res = fix(muted, white, target);
  console.log('target', target, '->', res.hex, res.ratio.toFixed(2) + ':1');
}

That keeps the hue family intact while meeting the number. If your brand color can't move, change the background, or use the brand color only for large text and non-text elements, where the 3:1 floor gives you more room.

Quick reference checklist#

  • Body text: 4.5:1 for AA, 7:1 for AAA.
  • Large text (18pt, or 14pt bold; about 24px or 18.5px bold): 3:1 for AA, 4.5:1 for AAA.
  • Icons, graphs, input borders and other active UI parts: 3:1 (1.4.11).
  • Don't round. 4.499:1 fails 4.5:1.
  • Check placeholder, hover and focus text too.
  • Logos and logotypes are exempt; other brand-mandated text is not.
  • Inactive controls and purely decorative text are exempt.
  • Use hex values from the stylesheet, not screenshots.
  • For thin fonts, aim above the minimum.
  • Run the palette script in CI or as a pre-commit step so failures show up before review. That's my suggestion, not a WCAG requirement.

For the source of the ratios, see MDN's color contrast guide and the W3C's Understanding SC 1.4.3.

Quick answers

Why is color contrast important for accessibility?

People with low vision or certain types of color blindness have trouble seeing edges, borders and details when light-dark differences are small. Adequate contrast keeps text and controls readable for them, and it helps everyone else too.

What contrast ratio does WCAG AA require?

AA requires 4.5:1 for body text and 3:1 for large text. User interface components and graphical objects such as icons and graphs also need 3:1.

Does the checker round the ratio?

It shouldn't. The W3C says computed values should not be rounded, so 4.499:1 does not meet the 4.5:1 threshold, even if a tool displays it as 4.50.

How do I check color contrast in Chrome, Figma or PowerPoint?

The sources I used only cover WebAIM's Color Contrast Checker and Firefox's Accessibility inspector. In any other tool, copy the foreground and background hex values and run them through a checker like the script above.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · GitHub · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS