Skip to main content
High contrast in Substrate is not an inversion, not a black-and-white fallback, and not a separate palette. It is one axis of the preference vector — contrastFactor — that multiplies every APCA target before the solver runs. The brand’s hues and chromas are untouched; only the lightness the solver lands on changes. APCA attribution and use restrictions: see About APCA. That distinction matters in practice: a high-contrast Substrate interface still looks like your product, because the only thing that moved is how far apart the foreground and background sit on the L axis.

The policy and the multiplier

The engine’s APCA policy is fixed and named. Three targets, all measured in APCA Lc against the context surface: contrastFactor scales all three together. It runs from 0.75 to 1.5, defaults to 1, and is bounded per brand by the flexibility.contrast range in the brand config — a brand can narrow how far its users may push the axis. The two high-contrast presets both set it to 1.3:
At 1.3×, the foreground target moves from Lc 75 to roughly Lc 97.5, and borders from Lc 50 to Lc 65. These are computed targets, not a fixed floor — there is no “Lc 90 mode” in the engine. Because contrast and scheme are independent axes, high contrast composes with any scheme position, including mid-track ones the presets don’t name.
Contrast is a root-level preference scalar, not a per-subtree attribute. You do not mark a container as high-contrast. What is scoped is the surface a solve runs against: a nested surface re-solves its foregrounds against its own background, at whatever contrastFactor is currently set.

Setting it

Like every axis, you set the number and re-sync:
Intermediate values are legitimate. 1.15 is a real, solvable preference — presets are just the labelled stops.

Following the operating system

The runtime ships a helper that maps the OS contrast preference onto the axis and keeps listening for changes. It handles both directions:
  • prefers-contrast: morecontrastFactor becomes 1.3
  • prefers-contrast: lesscontrastFactor becomes 0.85
The less direction is easy to overlook. Users who find high contrast uncomfortable — a common need for some forms of visual sensitivity and dyslexia — are served by the same axis running the other way, which is precisely why contrast is continuous rather than a boolean. A related but separate signal is forced-colors: active (Windows High Contrast). There the OS supplies the palette outright, so the runtime disables CVD compensation and lets the system colors through rather than competing with them.

What the no-JS floor covers

Two of the four baked snapshots are the high-contrast ones, gated on prefers-contrast: more:
That covers a server-rendered first paint at the page surface. Any other contrastFactor — including prefers-contrast: less at 0.85, and any user-chosen intermediate value — needs the runtime, as do nested surfaces. The snapshot is the accessible floor, not the whole model.

When a target can’t be met

The solver does not silently pass. It binary-searches the OKLCH lightness that hits the scaled target, and when the gamut runs out before the target is reached it records the shortfall as an unmetLc value on the solution rather than pretending it succeeded. At generation time this is enforced: the pipeline runs an accessibility gate across the whole brand corpus and fails the build on shortfalls, with output naming the count and kind of failure. Pushing contrastFactor toward 1.5 on a brand with very high chroma is the usual way to hit this — the fix belongs in the brand config, either by widening flexibility.contrast bounds deliberately or by adjusting the intent’s chroma. See the APCA solver reference.

Light & Dark

The scheme axis that composes with contrast, and what the runtime writes.

Color Vision

CVD compensation, which runs before the contrast solve rather than after it.