A common situation: the brand color is an orange such as #EA580C (Tailwind’s orange-600), the landing page sets body text in it on a cream #FFF7ED, and the accessibility audit reports that every body paragraph fails WCAG AA. The contrast ratio is 3.35 : 1, the threshold is 4.5 : 1. Now what — switch to flat black and lose the brand?

Open the Color Contrast Checker →

This guide is about the moment between “the audit failed” and “we shipped the fix.” It covers the WCAG 2.x contrast formula, the three thresholds you actually have to pass, the failure modes that surprise teams, and a concrete algorithm for keeping a brand hue while moving lightness just enough to clear AA.

What WCAG actually measures

WCAG 2.x contrast is a ratio of relative luminance, not perceived brightness. The formula has three steps:

  1. For each sRGB channel c, gamma-decode it:
    • if c / 255 ≤ 0.04045 then c_lin = (c / 255) / 12.92 (WCAG 2.0 and 2.1 print 0.03928; no 8-bit value falls between the two, so the results are identical)
    • otherwise c_lin = ((c / 255 + 0.055) / 1.055) ^ 2.4
  2. Combine the linearized channels into a single luminance:
    • L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
  3. Take the ratio with the lighter color on top:
    • ratio = (L_max + 0.05) / (L_min + 0.05)

The + 0.05 term is a flare offset that prevents the ratio from blowing up when one luminance is near zero. The values stay between 1 (no contrast) and 21 (pure black on pure white). Every AA/AAA threshold you see — 4.5, 7, 3 — is just a cut on this single number.

The luminance weights 0.2126 / 0.7152 / 0.0722 come from the ITU-R BT.709 spec for sRGB and they encode something physical: the human eye is most sensitive to green and least sensitive to blue. A pure red, a pure green, and a pure blue patch with identical RGB intensity look very different in luminance. Green carries most of the perceived light.

The three thresholds you actually have to pass

WCAG splits text and non-text into three buckets, each with its own AA and AAA target:

ElementAAAAACounts as
Body text (AA 1.4.3, AAA 1.4.6)4.5 : 17 : 1Smaller than 24 px regular or 18.66 px bold
Large text (AA 1.4.3, AAA 1.4.6)3 : 14.5 : 118.66 px bold and up, 24 px regular and up (WCAG defines 18 pt and 14 pt bold)
UI components & graphics (1.4.11)3 : 1—Borders, focus rings, icons, chart strokes

Three observations that trip up teams:

  1. AAA is not required by the common legal baselines. Section 508 incorporates WCAG 2.0 Level AA, and EN 301 549 (used for the European Accessibility Act) maps to WCAG 2.1 Level AA. W3C itself advises against requiring AAA for entire sites (Understanding Conformance). If a contract or internal policy asks for AAA on specific pages, it says so explicitly.
  2. Large text gets a much friendlier 3 : 1. This is why marketing landing pages with huge serif headings can use much lower-contrast accent colors than the body copy underneath them.
  3. There is no AAA target for UI. WCAG 2.1 added 1.4.11 at 3 : 1 and stopped there. Don’t waste time hunting an AAA UI rule that doesn’t exist; if you need stricter targets internally, write your own and document the basis.

Failure modes that surprise teams

The mistakes below are not exotic; each one is easy to ship without noticing.

”We meet AA, ship it” — but the audit tool measures something else

WCAG 2.x is not the only contrast model in use. APCA (Accessible Perceptual Contrast Algorithm) measures perceptual lightness contrast on a different scale (Lc), and several modern audit tools surface both. APCA was proposed for WCAG 3, which is still a Working Draft and does not set a normative contrast formula. If you build to WCAG 2.x and the audit reports APCA, the numbers will not match — that is not a bug, it is two different yardsticks.

For now, ship to WCAG 2.x because that is the standard referenced by current law (Section 508, EN 301 549, EAA). Track APCA separately as a research signal.

Semi-transparent text

Contrast formulas only work on opaque colors. If your text is rgba(20, 20, 20, 0.6) on a textured background, the actual rendered color depends on what is underneath. Composite the foreground over the actual background you expect, then run the contrast formula on the composited color. Most teams skip this step and ship contrast bugs that only appear over photos or gradients.

Mid-tone backgrounds break “just darken the foreground”

Against any opaque background, either pure black or pure white reaches at least 4.5 : 1, but on mid-tones only just. On #767676 black gives 4.62 : 1 and white 4.54 : 1; on #777777 white drops to 4.48 : 1. A colored foreground has no room left on such a background, so the fix is to change the background, not the foreground. Designers reach for “just bump the text color” reflexively, and on mid-tones the move does not work.

Placeholder text counts

Browsers render ::placeholder lighter than the typed text by default, and the exact color differs between browsers. Placeholder text is text, so 1.4.3 applies to it. Set an explicit ::placeholder color and measure that color against the input background.

Form labels and disabled states

1.4.3 exempts text that is part of an inactive user interface component. Whether a visible label next to a disabled control counts as part of the control is not spelled out in the criterion, and auditors differ. Keeping labels at 4.5 : 1 avoids the argument.

How “keep the hue, fix the lightness” works

When #EA580C on #FFF7ED fails AA at 3.35 : 1, the naive fix is “use black.” But brand colors are picked for hue identity. Black throws the brand away. The right move is to keep the hue and chroma constant and move only the perceived lightness until contrast clears.

OKLCH (Oklab in polar form) is the right space for this because its L axis is perceptually uniform: a step from L = 0.6 to L = 0.5 looks like the same magnitude of change as from L = 0.4 to L = 0.3. sRGB lightness does not have this property — equal sRGB steps look like very different perceptual jumps near the dark end.

The algorithm:

function fix(fg, bg, target = 4.5) {
  const fgLch = rgbToOklch(fg);          // keep hue h and chroma C
  // Try both directions: lower L (darker) and higher L (lighter)
  let best = null;
  for (const dir of [-1, 1]) {
    let lo = fgLch.L;
    let hi = dir < 0 ? 0 : 1;
    // Bisect: find smallest |delta L| from start that meets target.
    for (let i = 0; i < 22; i++) {
      const mid = (lo + hi) / 2;
      const trial = clampToSrgb(oklchToRgb({ ...fgLch, L: mid }));
      if (contrast(trial, bg) >= target) hi = mid;
      else lo = mid;
    }
    const final = clampToSrgb(oklchToRgb({ ...fgLch, L: hi }));
    const r = contrast(final, bg);
    if (r >= target * 0.99) {
      const delta = Math.abs(hi - fgLch.L);
      if (!best || delta < best.delta) best = { rgb: final, ratio: r, delta };
    }
  }
  return best ?? blackOrWhiteFallback(bg);
}

Two notes:

  • The target * 0.99 slack absorbs floating-point rounding from the OKLCH ↔ sRGB matrix. Rounding the result to an 8-bit hex value then moves the ratio again, in either direction, so measure the final hex before you ship it: WCAG thresholds are not rounded (4.499 : 1 fails).
  • The fallback to flat black or white is the honest answer when your hue cannot reach the target inside the sRGB gamut. A neon yellow on a yellow background will never pass AA at any lightness; the algorithm should admit that, not pretend.

For #EA580C (brand orange) on #FFF7ED (warm cream), the tool suggests #D03F00, which measures 4.50 : 1 (4.5025) as a hex value. The orange stays recognizable, though not exactly the same hue: lowering lightness pushes the blue channel below zero, and clipping it to the sRGB gamut shifts the OKLCH hue from 0.718 to 0.654 rad. The audit passes.

Code examples for your stack

Python (Pillow + manual luminance)

def srgb_to_lin(c):
    c = c / 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def luminance(rgb):
    r, g, b = (srgb_to_lin(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast(c1, c2):
    L1, L2 = sorted([luminance(c1), luminance(c2)], reverse=True)
    return (L1 + 0.05) / (L2 + 0.05)

# (234, 88, 12) on (255, 247, 237)
print(round(contrast((234, 88, 12), (255, 247, 237)), 2))  # 3.35

No external dependency. Pillow is only needed when you need to read pixel colors out of an image. For a known foreground/background pair, the math fits in fifteen lines.

JavaScript (browser, no library)

const lin = c => (c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const lum = ({ r, g, b }) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const ratio = (a, b) => {
  const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

That is the entire core. The OKLCH conversion adds another forty lines but stays inside one file. No chroma.js or culori import needed if you ship one tool — pull a library when you need ten color operations, not when you need one.

Bash (CI gate)

# Fail CI if any token in tokens.json is below 4.5:1 against the page background.
# wcagContrast(fg, bg) is the ratio function from the JavaScript example, taking hex strings.
node -e '
  const tokens = require("./tokens.json");
  const bg = tokens["color.background.default"];
  let failed = 0;
  for (const [k, fg] of Object.entries(tokens)) {
    if (!k.startsWith("color.text.")) continue;
    const r = wcagContrast(fg, bg);
    if (r < 4.5) {
      console.error(`FAIL ${k} ${fg} on ${bg} = ${r.toFixed(2)}`);
      failed++;
    }
  }
  process.exit(failed ? 1 : 0);
'

This is the version of accessibility testing that survives. Manual audits drift; a CI gate that reads design tokens and fails the build on regression catches issues before a human ever opens the pull request.

How this tool differs from the alternatives

WebAIM’s contrast checker is the de facto reference; we’re not trying to replace it. The differences worth knowing:

  • Hue-preserving fix: WebAIM reports pass or fail and gives lighten / darken buttons that step the color you picked. Chrome DevTools’ color picker can suggest a passing color for one element on a live page. This tool searches the OKLCH lightness axis for any pair you type and reports the smallest lightness change that clears AA.
  • Three-tier breakdown in one view: like WebAIM, it shows body text, large text, and UI components side by side, each against its own AA and AAA threshold.
  • Four languages: Useful when working with non-English design teams or shipping a11y guidelines internally.
  • No upload: Every conversion runs locally. The hex values you type never reach any server. For brand colors that are still under NDA, this matters.

Further reading

  • Color Palette Generator — pick a base color and get four harmonious schemes; useful before you check contrast.
  • Color Converter — HEX, RGB, HSL conversions when you’re moving values between design and code.
  • Color Shades Generator — Tailwind-style 11-step (50–950) scales when you need a full token set, not a single color.