---
title: "Increased contrast"
description: "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."
url: "https://opsinjs.pensievelabs.org/accessibility/increased-contrast"
source: "https://opsinjs.pensievelabs.org/accessibility/increased-contrast.md"
section: "Accessibility"
kind: "accessibility"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["high contrast mode", "forced-colors", "windows contrast themes"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="accessibility" />

## What we guarantee [#what-we-guarantee]

Today: &#x2A;*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`*&#x2A; and &#x2A;*`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](./contrast-conformance.mdx), 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](./colour-independence.mdx).
* **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 [#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.

<Callout>
  `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.
</Callout>

## What you own [#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 [#how-to-check]

<Steps>
  ### Emulate `prefers-contrast: more` [#emulate-prefers-contrast-more]

  In Chrome or Edge developer tools, Rendering → Emulate CSS media feature
  `prefers-contrast`. &#x2A;*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 [#turn-on-a-windows-contrast-theme]

  Settings → Accessibility → Contrast themes. This activates `forced-colors: active`
  in the browser. &#x2A;*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 [#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 [#measure-again-in-the-new-state]

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

## Measured results [#measured-results]

<NoDataYet script="scripts/check-contrast.mts" />

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 [#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](../components/surface.mdx) 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.

<Todo>
  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.
</Todo>

## Updates to this page [#updates-to-this-page]

<Reviewed />
