Color is where most accessibility failures start. Not because designers don't care about accessibility — they do — but because most color workflows don't include contrast checking until late in the process, when it's painful to change. This guide covers how to build accessible color palettes from the start, so contrast compliance becomes a design property of your tokens, not an afterthought.
The Core Problem: Color Chosen for Brand, Not Contrast
A typical brand color process goes: pick a hero color (your brand blue, purple, green), build a palette around it, then use those colors throughout the UI. Accessibility gets checked at the end, when audit findings say "your blue fails on white" — and suddenly you're dealing with either changing brand colors or shipping non-compliant text.
The better process: bake contrast into your token system. Define color pairs explicitly, not colors in isolation. A color that fails on white isn't inherently "wrong" — it just needs to be paired with a surface that gives it sufficient contrast.
Understanding the WCAG Thresholds
Before building a palette, know what you're designing for:
| Text type | AA minimum | AAA minimum |
|---|---|---|
| Normal text (< 18px regular, < 14px bold) | 4.5:1 | 7:1 |
| Large text (≥ 18px regular, ≥ 14px bold) | 3:1 | 4.5:1 |
| UI components & graphics | 3:1 | — |
AA is the legal minimum in most jurisdictions. AAA is aspirational for body copy and critical interfaces. For a full explanation of these thresholds, see WCAG Color Contrast Explained.
Use the FocusFlow Color Contrast Checker to check any color pair in real time before it enters your design system.
Building a Scale That Works
Most design systems define a color scale — typically 50 through 950 across hues. The accessibility insight here is knowing which stops on your scale work together.
Take a purple scale:
| Stop | Hex | On White (#fff) | On Black (#000) |
|---|---|---|---|
| 100 | #f3e8ff | 1.1:1 | 19.3:1 |
| 300 | #d8b4fe | 1.7:1 | 12.7:1 |
| 500 | #a855f7 | 3.5:1 | 6.0:1 |
| 600 | #9333ea | 4.9:1 | 4.3:1 |
| 700 | #7c3aed | 6.1:1 | 3.5:1 |
| 800 | #6b21a8 | 8.3:1 | 2.5:1 |
| 900 | #581c87 | 11.2:1 | 1.9:1 |
From this:
- Purple-600+ on white passes AA for normal text (4.5:1)
- Purple-700+ on white passes AAA for normal text (7:1) — purple-700 is close at 6.1:1, purple-800 clearly passes
- Purple-500 on white passes AA only for large text (3.5:1 ≥ 3:1)
- Purple-300 and lighter fail all contrast checks on white
- Purple-500 and darker on black fail — too much contrast is impossible, but some dark backgrounds need a lighter stop
What this tells you: Don't use your brand's mid-range purple for text on white. Use 700+ for text, and use 500 only for decorative elements or large display text.
Designing Semantic Color Pairs
Instead of defining colors, define semantic pairs: what color is used for what purpose, on what surface.
Text / Surface combinations to define explicitly:
Primary text #1e293b on #ffffff 16.7:1 ✓ AAA
Secondary text #475569 on #ffffff 7.3:1 ✓ AAA
Muted text #64748b on #ffffff 4.6:1 ✓ AA
Placeholder #94a3b8 on #ffffff 2.5:1 ✗ FAIL
Link #7c3aed on #ffffff 6.1:1 ✓ AA
Link (on dark bg) #c084fc on #1e293b 6.4:1 ✓ AA
Button label #ffffff on #7c3aed 6.1:1 ✓ AA
Button label (hover) #ffffff on #6d28d9 7.6:1 ✓ AAA
Error text #991b1b on #fef2f2 6.2:1 ✓ AA
Success text #14532d on #f0fdf4 7.8:1 ✓ AAA
Badge (info) #1e40af on #dbeafe 7.5:1 ✓ AAA
Notice the placeholder row: the common design pattern of light gray placeholder text on a white input fails AA. Fix it:
/* Failing default */
::placeholder { color: #94a3b8; } /* 2.5:1 on white — fail */
/* Passing replacement */
::placeholder { color: #6b7280; } /* 4.6:1 on white — AA pass */
Dark Mode: A Second Set of Pairs
Dark mode isn't "invert your palette" — it's a separate semantic mapping. Light palette stops that worked on white don't necessarily work on dark backgrounds.
| Semantic role | Light mode | Dark mode |
|---|---|---|
| Page background | #ffffff | #0f172a |
| Surface (card) | #f8fafc | #1e293b |
| Primary text | #1e293b | #f1f5f9 |
| Secondary text | #475569 | #94a3b8 |
| Accent (link) | #7c3aed | #c084fc |
| Button bg | #7c3aed | #7c3aed |
| Button label | #ffffff | #ffffff |
Check: #c084fc on #0f172a = 10.8:1 ✓. Dark mode link color passes AAA. #94a3b8 on #1e293b = 3.9:1 — this fails AA for normal text. Adjust to #a1a1aa or lighter.
Always build and audit your dark mode color table separately. Automated contrast tools that scan your live page will miss dark mode unless you explicitly load it.
Accessible State Colors
Interactive elements have multiple states, each needing its own contrast check:
Buttons
Default: #ffffff on #7c3aed — 6.1:1 ✓ AA
Hover: #ffffff on #6d28d9 — 7.6:1 ✓ AAA
Focus: Focus ring: #7c3aed outline on #ffffff page — 6.1:1 ✓ (ring vs background)
Disabled: #94a3b8 on #e2e8f0 — 2.0:1 — Intentionally low; document as exempt
Form fields
Default border: #d1d5db on #ffffff — 1.6:1 — fails Non-text Contrast (3:1)
Accessible border: #6b7280 on #ffffff — 4.6:1 ✓ (exceeds 3:1 requirement)
Error border: #dc2626 on #ffffff — 3.9:1 ✓
Focus border: #7c3aed on #ffffff — 6.1:1 ✓
The default #d1d5db input border is another nearly universal failure — it fails WCAG 1.4.11 (Non-text Contrast, 3:1). Darken your input borders.
Status / alert colors
Error bg/text: #991b1b on #fef2f2 — 6.2:1 ✓ AA
Warning bg/text: #92400e on #fffbeb — 7.1:1 ✓ AAA
Success bg/text: #14532d on #f0fdf4 — 7.8:1 ✓ AAA
Info bg/text: #1e40af on #eff6ff — 7.6:1 ✓ AAA
This is the "dark text on tinted background" pattern for alerts — significantly more accessible than "white text on saturated red/green/yellow."
Designing with Color Vision Deficiency in Mind
About 8% of men have red-green color vision deficiency (deuteranopia/protanopia). A color pair that looks clearly distinct to a person with full color vision may appear identical to someone with a deficiency.
Key principle: never use color alone to convey information. An error state marked only by a red border is invisible to someone who can't distinguish red from gray. Pair color with:
- An icon (error icon, checkmark)
- A text label ("Error" / "Success")
- A pattern or shape difference
<!-- Color-only: inaccessible -->
<input class="border-red-500" />
<!-- Color + icon + label: accessible -->
<input class="border-red-600" aria-invalid="true" aria-describedby="err" />
<div id="err" class="flex items-center gap-1 text-red-700">
<svg aria-hidden="true"><!-- error icon --></svg>
<span>Email address is invalid</span>
</div>
Test your UI with a browser's color vision simulation:
- Chrome DevTools → Rendering → Emulate vision deficiencies
- Firefox → DevTools → Accessibility → Simulate color blindness
Practical Color Palette Audit Process
- Export your color tokens — list every semantic color pair: background, text, border, icon
- Run each pair through a contrast checker — use the FocusFlow Color Contrast Checker or equivalent
- Tag each with its WCAG result — AA pass, AA fail, note if large-text-only
- Fix failing pairs — adjust the stop on your scale (usually 1–2 stops darker for text)
- Document the decisions — in your Figma library or design system docs, annotate which color combinations are approved for text use vs. decorative use only
- Set up CI checks — use axe-core in your CI to catch regressions when colors change
Quick Reference: Common Fixes
| Issue | Failing value | Fix |
|---|---|---|
| Placeholder text | #94a3b8 on white | Use #6b7280 (4.6:1) |
| Input border | #d1d5db on white | Use #6b7280 (4.6:1) |
| Medium blue on white | #3b82f6 (3.0:1) | Use #1d4ed8 blue-700 (7.8:1) |
| Medium green on white | #22c55e (2.8:1) | Use #15803d green-700 (6.1:1) |
| Medium purple on white | #a855f7 (3.5:1) | Use #7c3aed violet-700 (6.1:1) |
| Light gray on white | #9ca3af (2.9:1) | Use #6b7280 gray-500 (4.6:1) |
| Secondary text | #94a3b8 (2.5:1) | Use #64748b slate-500 (4.6:1) |
The pattern: "mid-range" stops on modern color scales (400–500) almost universally fail AA on white. Drop to 600–700 for text use.