Skip to main content
Around 8% of men and 0.5% of women have some form of color vision deficiency. When intents are distinguished only by hue — danger red against success green being the classic pair — those users lose the distinction entirely. Substrate treats CVD as a runtime user preference, not a brand configuration. There is no CVD array in the brand YAML and no alternate token set to generate. The user’s vision is described by two values, and the kernel redistributes intent hues to suit it before any contrast math runs.
Read Scope and limits before relying on this. Hue redistribution is an aid, not a guarantee, and it is never a substitute for non-color cues.

The preference

CVD is a small struct on the preference vector: a type and a continuous severity.
The four compensable types — plus none — are the kernel’s own identifiers, and they are spelled the same way in TypeScript, Swift, and Kotlin: Use these exact identifiers. The clinical names — protanopia, deuteranopia, tritanopia — do not appear in the API. Severity is continuous, from 0 (normal vision) to 1 (full dichromacy or monochromacy). It is a first-class dimension, not a switch: severity: 0.4 applies a proportionally weaker transform than 1.0, which matters because most CVD is partial. Setting type: 'none' or severity: 0 is the identity transform.
Because CVD is one axis among seven, it composes with the others for free. Dark plus deutan plus elevated contrast is simply three assignments to the same vector — there is nothing to nest and no combined artifact to generate. The runtime can also persist and restore the setting, and it defers to the OS when the OS is already handling color:

How compensation works

The transform order is fixed: warmth → cvd → apca. Color is adjusted for the user first, and contrast is solved against the already-compensated colors. Compensating after solving would invalidate the contrast guarantee.

Chromatic types

For protan, deutan, and tritan, the kernel redistributes hues onto a CVD-safe arc — the range of hues that remain discriminable under that deficiency: The redistribution applies to every intent in the map, not just the semantic ones. brand, neutral, and any custom intent you declare are all repositioned; nothing is exempt. Intents are ordered by their angular offset from the confusion-zone midpoint and mapped proportionally into the safe arc, subject to a minimum separation of 15° between adjacent hues so that neighbors don’t collapse into each other. This is a redistribution, not a lookup table. There are no hard-coded “red becomes amber” mappings — where a hue lands depends on the arc, the other intents present, and the severity.

Achromat

Monochromacy can’t be solved by moving hues, so achromat takes a different path: chroma is reduced proportionally to severity, reaching zero at severity: 1. Hue is left alone because it no longer carries information. Since color is then unavailable as a channel, the runtime sets data-cvd-achromat on the root element and the shipped CSS uses it to apply per-intent pattern overlays, so filled and subtle surfaces stay distinguishable by texture:
Each intent carries a --ucs-{intent}-pattern primitive alongside its numeric ones, mapped to --intent-pattern by the role selector.

Simulation

For design review, the runtime can render the page as a CVD viewer would see it rather than compensating for them. This sets data-cvd-sim on the root and applies a Machado simulation matrix as an SVG color filter. It is a devtool for checking your work, and the inverse of compensation — never enable both as a production path.

Scope and limits

The kernel carries these caveats in its own source, and they belong in front of anyone shipping this feature.
Hue redistribution helps dichromats most. It is weak for anomalous trichromats — people whose color perception is compressed rather than absent, and who are the majority of people with CVD. Treat compensation as an aid, not a guarantee.
  • Never rely on color alone. Information must not be conveyed by color by itself; pair it with text, icons, patterns, or position. This is WCAG 2.2 §1.4.1 Use of Color (Level A), and relatedly §1.4.11 Non-text Contrast (Level AA). Non-color cues are a requirement for all users, not a fallback that only monochromacy triggers.
  • The 15° minimum separation is an engineering heuristic, not a canonical or standardized threshold. It was chosen to work well in practice, and it has no normative backing.
  • Validate empirically. Run the Machado simulation for each CVD type against your actual palette and check pairwise perceptual distance (ΔE) between the colors that carry meaning. Do not assume the heuristic separation is sufficient for your specific set of intents.
The simulation basis is solid and citable: pre-computed 3×3 physiological matrices at 11 severity steps, from Machado, Oliveira & Fernandes (2009), IEEE Transactions on Visualization and Computer Graphics 15(6), interpolated for intermediate severities.

No media query for this

Operating systems expose prefers-color-scheme, prefers-contrast, and prefers-reduced-motion — but there is no CSS media query for color vision deficiency, and no plan for one. CVD compensation therefore has no no-JS fallback: it requires the runtime and an explicit user preference, which is why persisting the choice matters. Give users a control, save it, and restore it on load.

Modes Overview

The full preference vector CVD is one axis of, and what the no-JS floor covers.

High Contrast

The contrast solve that runs after CVD compensation.