opsinjs
AccessibilityContracts

Increased contrast

What prefers-contrast changes, what forced-colours mode does instead, and an honest account of the per-component answers that exist while no global one does.

What we guarantee

Today: nothing system-wide. This page exists because the gap is real and should be visible, not because there is a global feature to describe. app/globals.css implements prefers-reduced-motion and prefers-reduced-transparency; there is no prefers-contrast block and no site-wide forced-colors block. Several components now answer forced-colors: active on their own, and a grep of registry/bases/base for a forced-colors: variant class shows the seven below:

  • Surface draws its edge as an outline rather than a box-shadow, so a browser keeps it and recolours it to CanvasText on the four rungs that publish a border, and every card and tile built on Surface stays bounded.
  • Field switches an invalid control's border to dashed, so the invalid state still reads once the browser has repainted that border to a system colour.
  • Button gives its primary variant Highlight and HighlightText, so the recommended action stays filled and its label meets the system pairing.
  • ScoreDial and TrendSparkline map their SVG strokes to system colours, because forced colours recolours boxes and text but leaves strokes where the author put them.
  • RangeBar maps its boundary tick to CanvasText and its reading marker to Highlight inside a CanvasText outline, so the scale's edges and the current reading stay separable once the palette is replaced.
  • Sheet maps its scrim to Canvas, so the dimmed backdrop becomes the system background rather than a tint the browser would otherwise drop.

All of this lives in each component rather than in a stylesheet, and deliberately so. The product stylesheet does not travel with a shadcn add install while the component file does, so a rule that ships inside the component reaches the reader who installed only that component.

What the system does provide that makes the gap survivable in the meantime:

  • Every published token pair is measured and the results are on Contrast conformance, so the baseline is known rather than assumed.
  • Colour is never the only carrier of clinical status, which is the property that matters most in a forced-colours environment where the browser will replace your palette with the reader's. See Colour independence.
  • The colour engine can derive a higher-contrast variant of a ramp, because the roles are computed in OKLCH from lightness targets rather than hand-picked. That is what makes the intended implementation cheap.

What it will change

When it lands, @media (prefers-contrast: more) will do exactly four things, and deliberately not more:

  1. Raise the lightness distance between ink and surface in every status and category pair, so each pair clears a higher floor than the standard one.
  2. Make every border explicit. Hairline dividers at low alpha become solid at full token colour; the material ladder's borders stop being decorative.
  3. Thicken the focus indicator and give it a solid outer contour so it survives on any surface.
  4. Disable translucency and blur, by reusing the reduced-transparency degradation. High contrast and frosted glass are incompatible requests.

It will not change type size, spacing, layout or the meaning of any colour. A reader asking for more contrast is not asking for a different product.

prefers-contrast: more and forced-colors: active are different mechanisms and need different answers. The first is a request to your palette. The second replaces your palette with a system one, and the correct response is mostly to stop fighting it: keep borders declared, avoid conveying anything by background colour alone, and use forced-color-adjust only where a colour genuinely carries information that would otherwise be lost.

What you own

Two mechanisms, two answers. Forced colours is answered per component today, in the files listed above, so a product built on opsinjs does not have to redraw the boundary Surface already draws or the strokes ScoreDial already maps. What stays the product's there is its own colours, its own information-bearing images, and any boundary it draws with a shadow instead of a border, which are the three items below. prefers-contrast: more is the other half, and it has no token-layer escalation yet, so raising contrast on demand is still entirely the product's responsibility until the block above lands.

These stay yours under both mechanisms:

  • Any colour you introduce. Your brand accent, your chart series, your illustrations. The system can only escalate what it defines.
  • Images and icons that carry information. In forced-colours mode a photograph is unchanged while everything around it is recoloured, which can leave it illegible against its new surroundings.
  • Anything drawn with a shadow instead of a border. Shadows are removed in forced-colours mode. A card that reads as a card only because of its shadow becomes a flat run of text.

How to check

Emulate prefers-contrast: more

In Chrome or Edge developer tools, Rendering → Emulate CSS media feature prefers-contrast. Expected result today: the token layer changes nothing, because there is no @media (prefers-contrast: more) block in either stylesheet. Surface and DisclaimerNote do answer the variant in their own utilities, so on any page that renders a Card, Dialog, Sheet or DisclaimerNote you should see the surface go opaque, lose its backdrop blur and gain a thicker edge. Seeing those three changes and nothing else is the correct current outcome.

Turn on a Windows contrast theme

Settings → Accessibility → Contrast themes. This activates forced-colors: active in the browser. Expected result: the site remains usable, all text is legible, and every control still has a visible boundary. Failure looks like invisible buttons, a status pill whose text and background have both become the same system colour, and disappearing card edges.

Turn on the macOS and iOS setting

Settings → Accessibility → Display → Increase contrast. This drives prefers-contrast: more in Safari. Repeat the primary task.

Measure again in the new state

Any pair the browser has altered needs re-measuring. The published figures describe the default themes only.

Measured results

This table has not been generated. It is produced by scripts/check-contrast.mts. Run pnpm run generate and reload.

Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.

There is no increased-contrast theme to measure. When the escalation lands, the contrast script will emit a third column for every pair because a high-contrast mode that has never been measured is a claim, not a mode. The three columns will be light, dark and increased-contrast.

Known gaps

This page is mostly gap, so the list is the substance rather than a footnote.

  • prefers-contrast has no token-layer answer. No @media (prefers-contrast: more) block exists in app/globals.css or app/product.css, so the escalation this page specifies is still the one that is missing. It is answered per component instead: Surface maps contrast-more: to an opaque tint and a thicker edge in its own utilities, and DisclaimerNote thickens its rule under the same variant.
  • forced-colors is answered per component, has no site-wide block, and has not been tested on a real theme. Surface, Field, Button, ScoreDial, TrendSparkline, RangeBar and Sheet each handle it in their own file, but no site-wide @media (forced-colors: active) block exists and the site has never been opened under a Windows contrast theme.
  • There is no increased-contrast token set, so nothing can be measured.
  • prefers-contrast: less is not considered at all, and we have no view on what it should do.
Not written yet.

Implement the four-part escalation described above in the opsinjs token layer, add a forced-colors pass that guarantees a visible boundary on every control, and extend scripts/check-contrast.mts to emit measurements for the escalated theme.

Updates to this page

Last read through against the system on 2026-09-02. Due for review every 3 months; expiry is reported by pnpm run check:freshness.

On this page