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.
The preference
CVD is a small struct on the preference vector: a type and a continuous severity.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.
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
Forprotan, 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, soachromat 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:
--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 setsdata-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.- 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.
No media query for this
Operating systems exposeprefers-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.