AccessibilityApril 29, 202610 min read

Color Contrast: The Most Common Accessibility Bug — and How to Kill It at the Token Layer

Light gray on white looks elegant on your designer's retina display. To a 60-year-old reading on a phone in direct sunlight, it's a blank page. axe-core's color-contrast rule exists for the users you don't see in your office.

1. What is the color-contrast violation?

The rule measures the ratio between text and its background. The number ranges from 1:1 (identical, invisible) to 21:1 (pure black on pure white). WCAG defines these thresholds:

  • AA — Normal text: at least 4.5:1
  • AA — Large text (18pt or 14pt+ bold): at least 3:1
  • AAA — Normal text: at least 7:1
  • AAA — Large text: at least 4.5:1
  • UI components and focus rings (1.4.11): at least 3:1

axe-core reads the actual rendered pixels, places text against its computed background, and reports the strictest threshold the pair fails. A single line below the bar fails the rule for the whole page.

2. Why it matters

One in four adults wears corrective lenses, almost everyone over 50 has reduced contrast sensitivity, and one in twelve men is colorblind. Add it up and somewhere between 8 and 12 percent of your traffic is fighting low-contrast text. They're not running assistive technology — they're just leaving.

color-contrast is the single most reported violation in axe-core scans. Across recently crawled sites in the Keysonar dataset, nearly one in three accessibility issues traces to this rule. The cause is almost always the same: brand systems that codify #999, #aaa, #ccc as "muted text" and propagate them everywhere.

3. When does it fire?

Six recurring offenders we see in production crawls:

  1. Light gray body text on white: #888 on white is only 3.54:1 — below AA. By far the most common failure.
  2. Tone-on-tone branded button: Lavender text on a purple button. Looks harmonious in mockups, fails on shipping.
  3. Text over photography: A hero headline that lands on the bright part of the image. The same line passes against the dark side and fails against the sky.
  4. Disabled state text: Designers fade out "disabled" states with #bbb/#ccc. Even outside WCAG's exemption, the result becomes unrecognizable.
  5. Placeholder text: Form-field hint text is visible until the user types, and it must clear the same 4.5:1 bar.
  6. Inline links indistinguishable from body text: A blue close to the body color. If the link/body ratio is below 3:1 and there's no underline, readers can't see what is clickable.

4. The fixes

4.1 The light-gray-text problem

The most common offender by far: a designer set body text to #888 “to feel quieter.” Dropping it to #595959 clears AA without breaking visual hierarchy.

Yanlış / WrongContrast: 3.54:1 — fails AA
.body-text {
  color: #888;
  background: #fff;
}
Doğru / RightContrast: 7.04:1 — even passes AAA
.body-text {
  color: #595959;
  background: #fff;
}

4.2 Readable text on brand colors

Yellows, light greens, mints — bright brand surfaces resist white text. Either darken the surface for text use or pair it with the brand's deep counterpart.

Yanlış / WrongYellow (#FFD400) on white: 1.71:1 — unreadable
<button style="background: #FFD400; color: #fff">
  Add to cart
</button>
Doğru / RightYellow (#FFD400) on navy (#1A1A40): 13.6:1
<button style="background: #FFD400; color: #1A1A40">
  Add to cart
</button>

4.3 Text over photography

When a headline must sit on a photo, scrim it. A semi-transparent dark gradient under just the text guarantees the same baseline regardless of what part of the image is showing.

Doğru / RightPer-text scrim with linear-gradient
.hero {
  position: relative;
  background-image: url('/hero.jpg');
}

.hero::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    rgba(0,0,0,0.55) 0%,
    rgba(0,0,0,0.20) 70%
  );
}

.hero h1 {
  position: relative;
  z-index: 1;
  color: #fff;
}

4.4 Solve it at the design-system level

Codifying color in one place means contrast bugs get fixed in bulk, not one component at a time. When you ship tokens, document which surfaces each token is allowed against.

Doğru / RightToken-first approach — Tailwind config example
// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        // Allowed: only on white surfaces
        ink: {
          DEFAULT: '#1A1A1A',  // 16.5:1 ✓
          muted:   '#595959',  //  7.0:1 ✓
          subtle:  '#737373',  //  4.6:1 ✓ (borderline — avoid <12px)
        },
        // Allowed: only on dark surfaces
        snow: {
          DEFAULT: '#FFFFFF',
          muted:   '#D4D4D4',  //  9.0:1 ✓ (over #1A1A1A)
        },
      },
    },
  },
};

4.5 Disabled states

5. How the contrast ratio is computed

Browsers and axe-core apply WCAG's relative-luminance formula. Each color is converted from sRGB to linear light, then weighted by how the human eye perceives red, green, and blue:

jsWCAG relative-luminance formula
function relativeLuminance({ r, g, b }) {
  const channel = (c) => {
    c = c / 255;
    return c <= 0.03928
      ? c / 12.92
      : Math.pow((c + 0.055) / 1.055, 2.4);
  };
  return 0.2126 * channel(r)
       + 0.7152 * channel(g)
       + 0.0722 * channel(b);
}

function contrastRatio(fg, bg) {
  const L1 = relativeLuminance(fg);
  const L2 = relativeLuminance(bg);
  const [light, dark] = L1 > L2 ? [L1, L2] : [L2, L1];
  return (light + 0.05) / (dark + 0.05);
}

Notice the green channel weighs 0.7152 — by far the heaviest. Two colors with identical RGB spreads will read brighter when shifted toward green than toward blue. That asymmetry changes how you pick "equally muted" tones.

6. Cases that get missed

  • Hover and focus states: Designers usually QA the default state; the hovered color must clear AA too.
  • Translucent overlays: The actual color the user sees is the composited result. axe-core may not resolve the composite — verify manually.
  • Dark mode: A palette that's perfect in light mode does not auto-pass in dark mode. Audit them as separate themes.
  • Brand primaries: A required brand yellow/orange/mint should live on backgrounds and decorative shapes, not in body text. WCAG exempts logos; it does not exempt paragraphs.
  • Text painted into canvas / SVG: axe-core inspects the DOM. Text rasterized into a canvas is invisible to it — verify chart labels by hand.

7. How to test

  1. Chrome DevTools color picker: Click any color swatch in the Elements panel; it shows AA/AAA badges live. Fastest possible feedback during development.
  2. WebAIM Contrast Checker: Two-color web tool. Best for sampling middle stops in a gradient.
  3. Stark (Figma plugin): Audit every component variant before code is written — the cheapest place to fix contrast.
  4. Keysonar SEO Tools: Site-wide crawl that lists every page where a color-contrast violation lands and tracks regressions across releases. Crucial for catching small token drifts that ripple through dozens of templates.

8. Quick checklist

  • Body text against white meets at least 4.5:1.
  • Button labels (especially on brand colors) clear AA.
  • Placeholder text meets the same threshold as live user text.
  • Hover, focus, and active states stay above 4.5:1.
  • Text over imagery has a scrim or gradient guarantee.
  • Inline links contrast 3:1 with surrounding body text (or carry an underline).
  • Design tokens document the contrast ratio for each surface pairing.
  • Dark mode is audited independently of light mode.

9. References

  • WCAG 2.1 SC 1.4.3 — Contrast (Minimum) — Level AA
  • WCAG 2.1 SC 1.4.6 — Contrast (Enhanced) — Level AAA
  • WCAG 2.1 SC 1.4.11 — Non-text Contrast — Level AA
  • W3C: Web Content Accessibility Guidelines — Contrast Ratio
  • WebAIM: Understanding the Contrast Algorithm

Catch these violations automatically across your site

Keysonar SEO Tools crawls every page, runs the full axe-core ruleset including button-name, and gives you the exact list of affected URLs.

Start free