AccessibilityBy Sagar Chachapara · August 9, 2026 · 13 min read

WCAG Color Contrast Explained: How to Design Accessible Interfaces

Understand WCAG 2.1 and 2.2 color contrast requirements: 3:1 vs 4.5:1 ratios, how contrast is calculated, common failures, and how to fix inaccessible palettes.

Roughly one in twelve men has some form of color vision deficiency, and add aging eyes, sun-washed phones, and low-quality displays and "light gray on white" stops being an edge case — it's basic quality control. The clear answer up front: contrast is a calculated number, not a visual guess; normal text needs at least 4.5:1 against its background (3:1 for large text and UI components), and you can verify it in seconds — you just need to stop trusting what your bright office monitor tells you.

This guide covers what WCAG 2.1/2.2 actually require, how the math works, the failures that fail most palettes, and a workflow that makes accessible color the default rather than a late rework.

The two ratios you must remember

  • 4.5:1 — minimum for normal-size text (AAA goes further to 7:1)
  • 3:1 — minimum for large text (≥24px, or 18.66px bold), icons, and graphical UI components like buttons and focus indicators

Everything else is refinement. A quick landmark: #757575 on white is exactly 4.54:1 — any lighter gray fails AA for body text. That single data point disqualifies a shocking amount of "professional" design.

How the number is actually calculated

WCAG computes relative luminance (L) from a color's channels, then compares the two luminance values:

contrast = (L1 + 0.05) / (L2 + 0.05)

where L1 is the lighter color and L2 the darker. The math linearizes sRGB values before weighting them by human sensitivity (reds are weighted more heavily because the eye is more sensitive to them). You should not do this in your head; you should know what it means: perceived brightness, not hex darkness, drives the ratio. #333333 and #3A3A5C are both "dark," but their contrast against white differs meaningfully.

Three example palettes and whether they pass

Palette 1 — "AA-Safe SaaS": passes for body text

  • Background — #FFFFFF
  • Body text — #1F2D3D (navy ink)
  • Muted meta text — #5A6B7C
  • Primary button — #1B31E7 with white text

Contrast vs white: body = ~13.5:1 ✓, muted = ~5.1:1 ✓, button white-on-blue = ~6:1 ✓.

Use it: form-heavy product pages where long paragraphs and legal fine print must stay readable.

Palette 2 — "Low-Contrast Trap": fails for normal text

  • Background — #FFFFFF
  • Body text — #8C9BA8 (washed gray-blue)
  • Secondary text — #A9B7C3

Both fail 4.5:1 (≈2.7:1 and ≈2:1 respectively). It reads fine on a bright, brand-new monitor at 100% zoom and becomes unreadable on any older or backlit display.

Use it nowhere for text — reserve washed tones strictly for decorative 10% accents (borders, dividers, placeholder icons) where 3:1 isn't required, and never rely on color alone.

Palette 3 — "Dark-Mode Dashboard": needs component-level fixes

  • Background — #121212
  • Body text — #E6E6E6 ≈ 15:1 ✓
  • Disabled button — #2E2E2E with icon at 3:1 ✗ (needs the icon to hit 3:1 with a visible label or a stronger fill)

Use it: with the fix — disabled states on dark UIs routinely fail component contrast even when text passes. Test controls, not just paragraphs.

The colorblind test your palette will often fail

Contrast ratio is only half the story. WCAG requires you don't rely on hue alone to convey meaning ("Save is green, Delete is red"). Run every state-change palette through a colorblind check (deuteranopia and protanopia for men, tritanopia less commonly). Rule: pair every color-coded signal with a shape, label, or pattern. A red dot that's only red fails; a red dot with a border or icon passes.

Why your palette passes the calculator but still looks wrong

The 4.5:1 ratio is measured between text and its immediate background. Real pages layer: text on a card on a page background, all at different lightness. A 4.5:1 pair can visually disappear if the two colors are light and mid-tone and the surrounding surface is even lighter. This is why I test the final rendered page with the grayscale filter turned on (or by grabbing the luminance in my browser), not just the token pairs in Figma.

Personal tip: set your design file to view everything at 50% opacity background — no, that's a joke. Here's the real one: build your default text color first as #1F2D3D-class ink, then derive "muted" version by shifting lightness only in HSL and checking the count. Doing the math once per token-generation beats trusting a fifth monitor.

Building the check into your tokens, so failures can't sneak back

Accessibility wins when passing ratios are the path of least resistance, not a final audit. Three habits turn "check contrast" from a chore into a default:

  1. Derive muted tokens from passing ink, not from gray sliders. Start with the ink that already passes (say #1F2D3D at ~13.5:1), then produce "muted" by nudging lightness in HSL and re-measuring. Anyone reaching for a lighter gray is borrowing a failure; reach for the derived token instead.
  2. Lock the ratio into the token name. Name tokens by their role and target (text.body, text.meta, brand.primary) and document the ratio each is built to hold. When a designer needs "something lighter for secondary," the system points them at text.meta, which was made to pass — not at whatever gray currently pleases them.
  3. Make the check part of review, not release. A one-line automated check (luminance math on exported tokens) runs at PR time in CI. Manual testing catches layout-level contrast; automated checks catch the regressions someone introduced on a Friday.

The edges nobody tests: hover, focus, disabled, chars

Even teams with a solid body-text ratio forget the long tail of states:

  • Hover and focus. A link that darkens on hover but stays at 3.8:1 fails. Focus rings — often 2:1 against the page — fail the 3:1 component bar. Test every actionable state, including "focused but not hovered."
  • Disabled without a label. #D0D0D0 text on #FAFAFA fails, but disabled elements are exempt from normal contrast — if they're accompanied by a visible label or the state is evident another way. A disabled button that's only distinguishable by a lighter gray is still a failure of perception.
  • Placeholder text. WCAG doesn't exempt it when it conveys meaning. If a form's placeholder carries essential instructions, it must clear 4.5:1.
  • Chars and glyphs. Thin-stroked icons at #757575 can drop contrast in practice because glyph stroke width isn't accounted for in the ratio. When in doubt, keep icons at the same darkness as the ink.

The phrase I use with teams: "text is the floor, states are the ceiling." If your palette passes body text but fails every hover, focus, and disabled state, your accessible story is still incomplete.

Six common mistakes (and fixes)

  1. Designing contrast on a bright, calibrated office monitor. Your screen's vividness is lying about real-world viewing. Check ratios numerically.
  2. Making "all text" one color. Muted information shouldn't hit 4.5:1 while body text lazily gets the same treatment — or worse, a weaker gray. Give meta text its own passing token: #5A6B7C, not #8C9BA8.
  3. Passing text, failing controls. Buttons, icons, inputs, focus rings and placeholders are components with their own 3:1 requirement. Test them.
  4. Only testing light mode. Dark mode inverts surfaces; ratios that passed in light mode can flip. Test both, and keep dark surfaces desaturated to avoid halation.
  5. Using pure black text. On bright screens, #000000 can cause after-image halation. A soft ink like #1A1A2E is more comfortable and still passes easily.
  6. Trusting "it passes, the brand requires it." The brand requirement is not a legal exemption. If the brand gray can't make 4.5:1, the brand gets a second, accessible value — brands survive, lawsuits don't.

Frequently asked questions

Does WCAG require 4.5:1 for everything?

No. Norma-size text needs 4.5:1 (7:1 for AAA). Large text and graphical objects (icons, component boundaries) need 3:1. Incidental UI like disabled elements gets more leniency, but "disabled" shouldn't mean "invisible."

Is white on #1B31E7 really acceptable?

Yes — it's roughly 6:1, comfortably above 4.5:1. White-on-electric-blue is AA-safe; the common failure is very light blues or whites-on-pastels.

Does a dark mode palette get different rules?

The ratios are the same, but the typical failures differ — disabled fills, scrollbars, and subtle dividers fail silently in dark mode. Treat dark mode as its own palette, not an inversion.

What's the fastest way to check a palette in my workflow?

Drop the palette into the palette generator — it computes WCAG contrast for each combination as you lock colors — and verify the final rendered page in the browser with a contrast-checking browser extension before handoff.

Build accessibility into the palette, not the repair budget

Start a palette in the palette generator and lock colors only when their calculated ratios clear the bar; mature them into production-ready tokens with the palette exporter, which writes out the WCAG-checked values as CSS variables or Tailwind config so "the accessible version" is the only version your team has.

Written by Sagar Chachapara

Founder & Developer of Color Palette — Sagar Chachapara is an independent developer who builds color tools and writes about color theory, accessible design, and working with palettes in the browser.

sagarchachapara1997@gmail.com

Put it into practice

Try the techniques from this guide in our free tools — no sign-up required.

More in Accessibility · All articles · FAQ

Keep reading