The pain this chapter solves
You designed a color system you're proud of. Then someone mentions that 8% of males can't distinguish red from green. You have no idea if your theme is still readable for them — and checking requires a browser plugin, a screenshot, and a manual review for every color pair.
Chapter 15
Color Blindness Simulation
Color blindness is not rare
About 8% of males and 0.5% of females have some form of color vision deficiency. For a product with 100,000 users, that’s potentially 8,000 people seeing your interface differently from how you designed it.
The most common form — deuteranomaly (weak green cones) — affects about 5% of males. A red-green interface that looks polished to you may look like two shades of brown to them.
There are two failure modes:
- Color-only information: A status indicator that’s “red = error, green = ok” is invisible to protanopes and deuteranopes.
- Insufficient contrast after simulation: A text color that passes WCAG at 5:1 might drop to 2:1 after color blindness simulation if the colors converge.
salt-theme-gen handles both: you can simulate how any color or entire theme appears under any form of color vision deficiency, and the accessibility report is recomputed on the simulated values.
The 7 types
import type { ColorBlindnessType } from 'salt-theme-gen';
// Dichromacy — one cone type missing (full severity)
"protanopia" // no red cones — affects ~1% of males
"deuteranopia" // no green cones — affects ~1% of males
"tritanopia" // no blue cones — affects ~0.003% of population
// Anomalous trichromacy — one cone type weakened (60% severity)
"protanomaly" // weak red cones — ~1% of males
"deuteranomaly" // weak green cones — ~5% of males (most common overall)
"tritanomaly" // weak blue cones — ~0.01% of population
// Complete color blindness
"achromatopsia" // sees only luminance — very rare
Simulating a single color
import { simulateColorBlindness } from 'salt-theme-gen';
const original = '#e63946'; // vivid red
const simulated = simulateColorBlindness(original, 'deuteranopia');
// → approximately '#b56900' — appears brownish to deuteranopes
Returns a hex string. Input and output are always valid 6-digit hex colors.
A few anchors to build your intuition:
- White → white in all types (achromatic, no change)
- Black → black in all types
- Vivid red → brownish/olive in protanopia and deuteranopia
- Vivid green → similar brown in deuteranopia
- Blue → purple-pink in tritanopia
- Any color → gray in achromatopsia (pure luminance)
Simulating an entire theme
simulateTheme() applies the simulation to every color in a GeneratedTheme — semantic colors, tonal palettes, surface elevations, and state colors — and recomputes the accessibility and APCA reports on the simulated values:
import { generateTheme, simulateTheme } from 'salt-theme-gen';
const theme = generateTheme({ preset: 'ocean' });
const simulated = simulateTheme(theme, 'deuteranopia');
// simulated is a new GeneratedTheme — original is not mutated
simulated.light.colors.primary // the primary as seen by a deuteranope
simulated.light.accessibility // WCAG report recomputed on simulated colors
simulated.light.apca // APCA report recomputed on simulated colors
Non-color fields are unchanged — spacing, radius, font sizes are the same in the simulated theme.
The accessibility check workflow
This is the real value of simulateTheme: you can verify whether your theme remains accessible after simulation.
const theme = generateTheme({ preset: 'sunset' });
// Check all 7 types
const types: ColorBlindnessType[] = [
'protanopia', 'deuteranopia', 'tritanopia',
'protanomaly', 'deuteranomaly', 'tritanomaly',
'achromatopsia',
];
for (const type of types) {
const sim = simulateTheme(theme, type);
const failures = Object.entries(sim.light.accessibility)
.filter(([, entry]) => entry.level === 'fail')
.map(([key]) => key);
if (failures.length > 0) {
console.warn(`${type}: WCAG failures after simulation —`, failures);
}
const apcaFailures = Object.entries(sim.light.apca)
.filter(([, entry]) => entry.level === 'fail')
.map(([key]) => key);
if (apcaFailures.length > 0) {
console.warn(`${type}: APCA failures after simulation —`, apcaFailures);
}
}
If textOnBackground is 'fail' after deuteranopia simulation, your body text is not readable for deuteranopes.
What it changes — and what it doesn’t
| Field | Simulated | Notes |
|---|---|---|
colors | Yes | All 23 semantic tokens |
palettes | Yes | All 88 tonal palette steps |
surfaceElevation | Yes | All 4 elevation levels |
states | Yes | All 32 state colors |
accessibility | Yes | Recomputed on simulated colors |
apca | Yes | Recomputed on simulated colors |
spacing | No | Not color — unchanged |
radius | No | Not color — unchanged |
fontSizes | No | Not color — unchanged |
iconSizes | No | Not color — unchanged |
The simulation math
simulateColorBlindness uses the Machado et al. 2009 matrices (published in IEEE Transactions on Visualization and Computer Graphics). These are the same matrices used by Storybook’s accessibility addon, Figma’s color blindness preview, and Adobe’s accessibility tools.
The pipeline:
hex → sRGB → linear sRGB → apply 3×3 matrix → blend by severity → clamp → linear sRGB → sRGB → hex
The matrices operate in linear sRGB space (not gamma-corrected), which is required for physically accurate simulation. Each matrix row sums to 1.0, which is why white (1,1,1) maps to white (1,1,1) under all dichromatic simulations.
Anomalous trichromacy types (protanomaly, deuteranomaly, tritanomaly) use the same matrices but blend 60% toward the full simulation instead of 100%:
result = original + (simulated - original) × 0.6
This models the “weakened but not absent” cone response.
Achromatopsia
Achromatopsia (complete color blindness) is handled separately — not by matrix, but by converting to a luminance-only grayscale:
Y = 0.2126 × R_linear + 0.7152 × G_linear + 0.0722 × B_linear
The relative luminance weights are the same as those used in WCAG contrast calculation. Achromatopsia simulation is the most severe case — it removes all hue and saturation information, leaving only brightness.
Using simulation in a test suite
Add automated accessibility checks under simulation to your CI pipeline:
import { generateTheme, simulateTheme } from 'salt-theme-gen';
import { describe, it, expect } from 'vitest';
const theme = generateTheme({ primary: '#your-brand-color' });
const types = ['protanopia', 'deuteranopia', 'tritanopia', 'achromatopsia'] as const;
describe('color blindness accessibility', () => {
for (const type of types) {
it(`textOnBackground passes WCAG AA under ${type}`, () => {
const sim = simulateTheme(theme, type);
expect(sim.light.accessibility.textOnBackground.level).not.toBe('fail');
expect(sim.dark.accessibility.textOnBackground.level).not.toBe('fail');
});
it(`onPrimaryOnPrimary passes WCAG AA under ${type}`, () => {
const sim = simulateTheme(theme, type);
expect(sim.light.accessibility.onPrimaryOnPrimary.level).not.toBe('fail');
});
}
});
This catches regressions when you change presets or input colors — your theme must remain accessible not just in normal vision, but across the most common deficiency types.
Building a preview UI
simulateTheme returns a complete GeneratedTheme, so you can render your actual component library with the simulated theme to see exactly what users with each condition see:
import { useState } from 'react';
import { generateTheme, simulateTheme } from 'salt-theme-gen';
import { generateCssVariables } from 'salt-theme-gen';
import type { ColorBlindnessType } from 'salt-theme-gen';
const baseTheme = generateTheme({ preset: 'ocean' });
const types: { label: string; value: ColorBlindnessType | null }[] = [
{ label: 'Normal vision', value: null },
{ label: 'Deuteranomaly (most common)', value: 'deuteranomaly' },
{ label: 'Deuteranopia', value: 'deuteranopia' },
{ label: 'Protanopia', value: 'protanopia' },
{ label: 'Tritanopia', value: 'tritanopia' },
{ label: 'Achromatopsia', value: 'achromatopsia' },
];
export function ColorBlindnessPreview() {
const [selected, setSelected] = useState<ColorBlindnessType | null>(null);
const theme = selected ? simulateTheme(baseTheme, selected) : baseTheme;
const { css } = generateCssVariables(theme);
return (
<div>
<select onChange={(e) => setSelected(e.target.value as ColorBlindnessType || null)}>
{types.map(({ label, value }) => (
<option key={label} value={value ?? ''}>{label}</option>
))}
</select>
{/* Inject simulated CSS variables into a scoped container */}
<style>{`#preview { ${css} }`}</style>
<div id="preview">
{/* Your actual UI components go here */}
{/* They will render using the simulated color variables */}
</div>
{/* Accessibility report on simulated colors */}
<ul>
{Object.entries(theme.light.accessibility).map(([key, entry]) => (
<li key={key} style={{ color: entry.level === 'fail' ? 'red' : 'green' }}>
{key}: {entry.ratio.toFixed(1)} ({entry.level})
</li>
))}
</ul>
</div>
);
}
Best practices
Don’t rely on color alone. Simulation reveals if your UI works when colors converge, but the underlying fix is always to add a non-color signal: icons, labels, patterns, or position. A status badge should say “Error” or show an ✕ icon — not just be red.
Prioritize deuteranomaly. It affects ~5% of males and is the most common deficiency. If your theme passes under deuteranomaly, the other anomalous types will likely pass too.
Achromatopsia is the stress test. If critical information is readable in grayscale, it is readable for everyone. Use achromatopsia simulation as the ultimate accessibility check — if contrast holds in pure luminance, it holds for every sighted user.
Run checks in CI. simulateTheme is fast and synchronous. Add it to your test suite as shown above so regressions are caught before they ship.