Colorblind-Safe Palettes for Categorical Data
The default red/green/brown category colors that look distinct to you can collapse into near-identical grays for the roughly 1 in 12 men with color vision deficiency, making your chart unreadable. This guide shows how to choose categorical palettes that survive deuteranopia, protanopia, and tritanopia, add redundant encoding so color is never the only signal, and verify the result with contrast checks.
This guide belongs to the color and contrast encoding section under the accessible interactive data visualization overview. When color alone is not enough, the redundant-encoding patterns here connect directly to the keyboard and screen-reader techniques in the sibling screen-reader-friendly charts guides.
Diagnostic checklist
Verify these failure modes before swapping palettes:
How a CVD-safe categorical palette is built
A safe categorical palette varies hue and lightness so categories stay separable even when hue collapses, and pairs each color with a redundant non-color cue. Below, the same eight-category palette is shown in normal vision and as it appears under deuteranopia.
Broken vs fixed
// ❌ BROKEN: red/green categories, color is the only signal
const palette = ["#22c55e", "#ef4444", "#eab308", "#84cc16"]; // green, red, yellow, lime
// Under deuteranopia/protanopia these greens, reds, and yellow-greens
// converge to similar muddy tones at nearly the same lightness.
function Legend({ series }: { series: string[] }) {
return (
<ul>
{series.map((name, i) => (
// swatch only — no shape, no pattern; nothing to fall back on
<li key={name}><span style={{ background: palette[i] }} className="swatch" /> {name}</li>
))}
</ul>
);
}
// ✅ FIXED: CVD-safe hues, varied lightness, redundant shape encoding
// Hues chosen to stay separable across deuteranopia, protanopia, tritanopia,
// and spread across the lightness axis so they differ even in grayscale.
const palette = ["#2563eb", "#d97706", "#0891b2", "#7c3aed"] as const; // blue, amber, teal, violet
const shapes = ["circle", "square", "triangle", "diamond"] as const; // A11Y: second channel
interface SeriesStyle { color: string; shape: typeof shapes[number]; }
function styleFor(i: number): SeriesStyle {
// PERF: O(1) lookup, computed once per series, not per point
return { color: palette[i % palette.length], shape: shapes[i % shapes.length] };
}
function Legend({ series }: { series: string[] }) {
return (
<ul>
{series.map((name, i) => {
const s = styleFor(i);
return (
<li key={name}>
{/* A11Y: swatch AND a shape glyph AND the text label */}
<ShapeGlyph shape={s.shape} color={s.color} aria-hidden="true" /> {name}
</li>
);
})}
</ul>
);
}
The broken palette leans on red versus green at similar lightness with color as the sole encoder. The fixed palette uses hues that stay distinct under all three common deficiencies, spreads them across the lightness axis (so they even differ in grayscale), and adds a distinct marker shape per series so color is never the only signal.
Step-by-step fix
- Replace the palette with CVD-safe hues. Start from a researched categorical set (the Okabe-Ito 8-color set or ColorBrewer qualitative palettes are well established). Keep the first 4–6 colors and store them as a
readonlytuple typedas const. - Spread across the lightness axis. Order the palette so adjacent series differ in perceived lightness, not just hue. A quick check: convert each color to grayscale and confirm consecutive entries are visibly different.
- Add a redundant channel. Assign each series a distinct marker shape, dash pattern, or texture in addition to color. Encode it in a
SeriesStyleobject so chart and legend share one source of truth. - Encode the legend redundantly too. Render the legend swatch as the actual marker shape, not a plain square, and always include the text label. Mark purely decorative glyphs
aria-hidden="true". - Check contrast. Ensure each series color meets at least 3:1 against the chart background (WCAG 1.4.11, Non-text Contrast) so the marks are perceivable. Compute the contrast ratio programmatically against your surface color.
- Simulate the three deficiencies. Run the rendered chart through deuteranopia, protanopia, and tritanopia simulation (browser DevTools “Emulate vision deficiencies” or a CVD simulator) and confirm every category remains distinguishable.
- Avoid encoding meaning in hue alone for status. For semantic colors (good/bad), pair red/green with icons or text (a check vs a cross), never color by itself.
Verification
- Grayscale test. Render the chart, desaturate it (CSS
filter: grayscale(1)or a screenshot pass), and confirm every category is still distinguishable. If two merge, push their lightness apart. - DevTools CVD emulation. In Chrome Rendering tab, enable each of “Emulate vision deficiencies” and visually confirm series separation.
- Programmatic contrast assertion. Compute the contrast ratio of each series color against the surface and assert it:
console.assert(contrast(color, surface) >= 3,${color} below 3:1). - Legend mapping. With color stripped, confirm a user can still map each legend entry to its series via shape and label alone.
- No color-only status. Audit any good/bad indicators to confirm an icon or text accompanies the color.
Edge cases & gotchas
- More than 8 categories. No categorical palette stays reliably distinguishable past roughly 8 colors, for anyone. Beyond that, group small categories into “Other”, use direct labeling on the marks, or switch to faceting (small multiples) so each panel needs fewer colors.
- Dark mode shifts contrast. A palette that passes 3:1 on a light surface can fail on a dark one. Maintain separate light and dark palettes, or derive colors with a contrast-aware adjustment against the active theme surface.
- Transparency stacks. Overlapping semi-transparent marks (alpha blending in dense scatter plots) blend hues into new colors that may no longer be CVD-safe. Where marks overlap heavily, prefer redundant shape/position cues; this ties into the dense-rendering tradeoffs covered in SVG vs Canvas architecture.
Frequently Asked Questions
Why isn’t avoiding red and green enough?
Avoiding red/green helps with the two most common deficiencies but not tritanopia (blue-yellow), and it ignores the deeper problem: if color is your only encoding channel, any deficiency or even a grayscale print will break the chart. The durable fix is redundant encoding plus lightness variation, not just a different hue set.
Which ready-made palette should I start from?
The Okabe-Ito eight-color qualitative set was designed specifically for color vision deficiency and is a strong default. ColorBrewer’s qualitative palettes are also well validated. Pick one, then verify it against your specific background and simulate the deficiencies rather than trusting the name.
How do I add a redundant channel without cluttering the chart?
Marker shape works for scatter and line markers; dash patterns work for lines; direct labels on the marks work when there are few series. You rarely need all of them — one reliable second channel plus distinct lightness is usually sufficient. Keep glyphs aria-hidden so they do not add noise for screen readers.
What contrast ratio do chart colors need?
Graphical objects and their boundaries should meet at least 3:1 against adjacent colors under WCAG 1.4.11 (Non-text Contrast). Text labels follow the stricter text rules (4.5:1 for normal text). Series fills against the chart surface should clear 3:1 so the marks are perceivable.
Related
- Color and contrast encoding — the parent section for perceptual encoding.
- Exposing chart data as an accessible data table — the ultimate redundant channel when color cannot carry meaning.
- SVG vs Canvas architecture — how renderer choice affects dense, overlapping marks.