Designing Accessible Color Systems for Data Visualization: A Step-by-Step Guide to Palettes That Work for Everyone
Updated

Designing Accessible Color Systems for Data Visualization: A Step-by-Step Guide to Palettes That Work for Everyone

by Julio Song

Open any popular charting library's default palette and run it through a deuteranopia simulator. The reds and greens collapse into nearly identical muddy ochres, and your five-category legend suddenly looks like three. Roughly 8% of men and 0.5% of women experience some form of color vision deficiency. That's approximately 350 million people worldwide staring at charts they can't fully decode.

Yet most palette generators, from Coolors to Adobe Color, optimize for aesthetic harmony, not perceptual distinguishability. This guide walks you through building three production-ready palettes (sequential, diverging, and categorical) that survive colorblind simulation, maintain APCA-compliant contrast, and use lightness variation as a built-in safety net. Each section ends with a palette you can paste into Figma, D3.js, or your dashboard tool.

Why most data viz palettes fail (and what colorblindness does to your charts)

Color vision deficiency (CVD) comes in several forms. The three most common are:

  • Deuteranopia (reduced green sensitivity): affects ~6% of men. This is the big one.
  • Protanopia (reduced red sensitivity): affects ~2% of men. Also red-green, but with a different perceptual shift.
  • Tritanopia (reduced blue-yellow sensitivity): rare, affecting less than 0.01% of the population.

Red-green deficiency dominates. When you combine deuteranopia and protanopia, roughly 8% of men can't reliably distinguish red from green. That matters for data visualization because red and green are the most common way dashboards encode "good vs. bad".

Take the defaults. Tableau's categorical palette includes distinct orange and green swatches that, under simulated deuteranopia, converge into the same brownish-yellow tone. Chart.js defaults suffer similar problems: its red and green series lines become nearly identical. Any chart relying on hue alone to carry meaning is one colorblind viewer away from being unreadable.

The fix starts with understanding three properties of color: hue (the color family), saturation (intensity), and lightness (how bright or dark). Of these, lightness is the universal channel. It survives every form of CVD. A dark blue and a light orange remain visually distinct even when their hues shift, because their luminance values are far apart.

Comparison of a bar chart under normal vision, deuteranopia simulation showing collapsed red-green colors, and grayscale rendering demonstrating how lightness variation preserves readability.

Every palette in this guide gets tested against three criteria:

  1. Perceptual distance: ΔE ≥ 20 between adjacent steps in CIELAB color space.
  2. APCA contrast: Lc ≥ 45 against the intended background.
  3. CVD simulation: visual distinguishability under both deuteranopia and protanopia.

If a palette clears all three, it works for everyone.

The toolchain: setting up your testing workflow

You don't need expensive software. This workflow covers it:

  • Palette design: Use the OKLCH color space (supported in CSS Color Level 4 and several Figma plugins). OKLCH is perceptually uniform, meaning equal numeric steps in lightness actually look equal to the human eye. HSL fails here badly: HSL lightness of 50% for yellow looks far brighter than 50% for blue.
  • CVD simulation: Sim Daltonism (macOS) or Coblis (web) lets you preview any design under all three CVD types in real time.
  • Contrast checking: The APCA contrast calculator at apcacontrast.com gives you Lc values that are more perceptually accurate than the older WCAG 2.x ratio method.

The main technique is the lightness ramp. Before you pick any hues at all, define a lightness skeleton. For a 4-step sequential palette, you might set L values at 35, 50, 65, and 80. This skeleton guarantees that your palette remains readable in grayscale, which means it also survives CVD.

For automation, keep these tools in your back pocket: chroma.js for JavaScript-based color interpolation, Leonardo Color (by Adobe) for generating contrast-ratio-based scales, and viz-palette.com for quick simulation previews of full palettes.

Building a sequential palette: light to dark in one hue family

Sequential palettes represent ordered data: population density, temperature ranges, revenue tiers. The viewer needs to instantly perceive a clear progression from low to high.

Here's the step-by-step process:

  1. Choose a hue anchor in OKLCH. Blue at H=250 is a reliable starting point because it maintains good chroma range across lightness levels.
  2. Define 5 lightness stops from L=92 down to L=30, with roughly even perceptual spacing (92, 76, 60, 45, 30).
  3. Let chroma peak in the mid-range (around L=60) and taper at the extremes. Very light and very dark blues look washed out or muddy at high chroma, so a bell-curve approach feels natural.
  4. Verify each step has ΔE ≥ 15 from its neighbors. For a 5-step palette, this threshold provides clear separation without requiring extreme lightness jumps.

This palette passes both deuteranopia and protanopia simulation because it relies on lightness progression, not hue variation. The hue stays in the blue family throughout, which is the channel least affected by red-green CVD.

A common mistake: designers who build sequential palettes by simply reducing the opacity of a single color. This creates unpredictable mid-tones whose actual displayed color depends on the background. A 50% opacity blue over white is a pale blue. The same 50% blue over a dark sidebar is something entirely different. Always use fully opaque color stops.

APCA check: The darkest step (L=30) achieves Lc ≥ 70 against a white background, making it safe for text overlays. The lightest step (L=92) achieves Lc ≥ 60 against dark backgrounds. Both ends of the ramp support readable labels.

Building a diverging palette: two hues that survive colorblind simulation

Diverging palettes show deviation from a midpoint: profit vs. loss, above vs. below average, positive vs. negative sentiment. They need two visually opposing endpoints with a neutral center.

The classic red-to-green diverging palette is the single worst choice for accessibility. It's the exact hue pair that collapses for 8% of men. Don't use it. Ever.

Instead, lean on safe diverging pairs that maintain separation under CVD. These combinations work because they differ in both lightness AND in the blue-yellow channel, which CVD preserves:

  • Blue to orange: the safest default. It has strong lightness contrast and stays distinct under all CVD types.
  • Purple to green: effective when blue is already in use for your sequential palette.
  • Teal to brown: a warm/cool contrast that reads well in grayscale.

Here's the build process for a 7-step blue-to-orange diverging palette:

  1. Select two endpoint hues at least 120° apart in OKLCH with different inherent lightness ranges. Blue (H=250) naturally sits darker; orange (H=70) naturally sits lighter.
  2. Define a neutral midpoint at L=88, C=0.02 in a warm gray. This "zero" point should feel like an absence of signal.
  3. Create 3 steps on each side with symmetric lightness ramps diverging from center. The blue side darkens (88 → 65 → 45 → 30); the orange side also darkens but follows orange's natural lightness range (88 → 72 → 55 → 40).
  4. Simulate under CVD and verify that the two endpoints remain clearly distinguishable.

Under deuteranopia simulation, the blue tones shift slightly but remain recognizably cool, while the orange tones shift toward brownish-gold but stay warm. Under grayscale conversion, the asymmetric lightness values keep every step distinct.

A blue-to-orange diverging palette shown under normal vision, deuteranopia simulation, and grayscale, demonstrating that the palette remains distinguishable across all three conditions.

Building a categorical palette: the hardest problem in accessible color

Categorical palettes assign colors to unordered groups: product lines, countries, user segments. This is the hard one, because every color must be distinguishable from every other color, including ones that aren't neighbors. For 6 colors, that's 15 pairwise combinations to check.

The way through is maximum lightness spread. Instead of choosing colors at equal lightness (which fails under CVD because hue is the only differentiator), deliberately assign each category a different lightness value. When hues merge under CVD, the value differences remain.

Here's the selection process:

  1. Define 6 lightness targets spread across the 30-90 range: 35, 45, 55, 65, 78, 90.
  2. Assign hues that maximize OKLCH hue distance while avoiding the red-green confusion zone. Favor blues, oranges, purples, and teals over pure reds and greens.
  3. Check all 15 pairwise combinations for ΔE ≥ 20 in CIELAB.
  4. Run CVD simulation and adjust any collapsing pairs by shifting lightness at least 15 points apart.

Keep categorical palettes to 6-8 colors maximum. Beyond that, color discrimination breaks down even for people with typical vision. When you need more categories, introduce supplementary encoding: pattern fills (crosshatch, dots, diagonal lines), shape variation in scatter plots, direct labeling on chart elements, and interactive highlighting on hover. All of these reduce how much the chart depends on telling colors apart.

Case study: redesigning a SaaS dashboard palette

Picture a B2B analytics dashboard, the kind you'd find in tools like Mixpanel or Metabase. It has four chart types: a line chart with 4 categorical series, a choropleth map with 5 sequential steps, a bar chart showing year-over-year change with 7 diverging steps, and KPI cards with colored status indicators.

Before, it looks like most dashboards. The line chart uses saturated rainbow colors (red, green, blue, yellow). The choropleth uses opacity steps of a single green. The YoY bar chart runs red-to-green for loss-to-gain. Status indicators are red circles for "bad" and green circles for "good." Under deuteranopia simulation, the line chart's red and green series merge. The status indicators become identical. The diverging bar chart looks like a monochrome gradient.

The fix applies the three palettes from this guide:

  • The line chart gets the categorical palette, with the darkest value (Dark Indigo, L=35) assigned to the primary metric, making the most important series the most visually prominent.
  • The choropleth gets the sequential blue palette, replacing the opacity-based green ramp.
  • The YoY bar chart gets the blue-to-orange diverging palette, replacing the inaccessible red-to-green.
  • Status indicators swap from red/green circles to blue/orange badges with checkmark and warning icons. The icon reinforcement means color is no longer the sole information carrier.

The redesigned palette passes the Stark plugin's full CVD simulation suite across all four chart types. Every data element achieves APCA Lc ≥ 45 against its background. The whole dashboard stays legible when printed in grayscale.

A redesigned SaaS dashboard featuring accessible color palettes: categorical line chart with varied lightness, sequential blue choropleth, blue-orange diverging bar chart, and icon-reinforced status indicators.

Checklist and design tokens

Run through this for every palette you create:

  1. Build the lightness skeleton first.
  2. Choose hues in OKLCH.
  3. Verify ΔE ≥ 20 between all pairwise combinations.
  4. Run deuteranopia + protanopia simulation.
  5. Check APCA contrast against all intended backgrounds.
  6. Test in grayscale.
  7. Add non-color encoding (icons, patterns, labels) as backup.

Export your palettes as design tokens so they stay consistent across platforms. Here's a minimal JSON structure:

{
  "color": {
    "sequential": {
      "blue-100": { "value": "#E8F0FE" },
      "blue-200": { "value": "#8AACDC" },
      "blue-300": { "value": "#4A79B5" },
      "blue-400": { "value": "#2B5490" },
      "blue-500": { "value": "#132B4F" }
    },
    "diverging": {
      "orange-strong": { "value": "#A85A1B" },
      "orange-mid": { "value": "#D4935A" },
      "orange-light": { "value": "#F0C9A0" },
      "neutral": { "value": "#DEDAD6" },
      "blue-light": { "value": "#92B4D4" },
      "blue-mid": { "value": "#4A79B5" },
      "blue-strong": { "value": "#132B4F" }
    }
  }
}

This structure maps directly to Figma variables, CSS custom properties (--color-sequential-blue-300), and Tailwind config extensions. Keep a single source of truth and generate platform-specific outputs from it.

One maintenance habit: whenever you add a new color to the system, re-run the full pairwise check against every existing color, not just the nearest neighbor. CVD collisions can occur between non-adjacent colors that happen to share similar lightness after hue information is stripped away.

Resources to bookmark:

Start with lightness

Lightness is the one channel every viewer gets. If you build each palette on a lightness skeleton first, the chart still communicates through value when hue fails.

The three palettes here (a sequential blue ramp, a blue-orange diverging scale, and a categorical set with varied lightness) aren't compromises. They encode information redundantly, so they hold up under more viewing conditions. Run every new color through simulation and a contrast check before it ships. If your chart reads in grayscale, it reads for everyone.

Share this article

Written by

Julio Song

Designer and developer

Julio Song is a professional web designer and developer who builds and maintains ColorSift, and writes most of what is published here.