---
title: "The contrast floor"
description: "Every translucent surface gets a minimum backing tint, so text contrast never depends on what is behind it, plus the worst-case composite and the scrim rule."
url: "https://opsinjs.pensievelabs.org/foundations/materials/the-contrast-floor"
source: "https://opsinjs.pensievelabs.org/foundations/materials/the-contrast-floor.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["minimum opacity", "backing tint", "scrim", "worst case composite", "text over image", "glass legibility"]
---

> 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="foundation" />

## Overview [#overview]

Put a translucent card over a photograph and the contrast of the text on that
card is no longer a property of your design. It is a property of the
photograph. In a consumer health product the photograph is frequently chosen
by the user, or is a camera feed, or is a chart whose shape changes with the
data.

This is the page that separates a design system from a screenshot. The rule is
one sentence:

> **Every translucent rung carries a minimum backing tint chosen so that the
> text on it clears the contrast floor against the worst possible backdrop.**
>
> Not against the backdrop in the mock. Against black, and against white, and
> therefore against everything in between.

The alpha of a material is not an aesthetic parameter. It is the output of that
calculation, and it is a token for the same reason a password minimum length is
a constant rather than a preference.

## How it works [#how-it-works]

### A translucent surface has a bounded range, and that is the whole trick [#a-translucent-surface-has-a-bounded-range-and-that-is-the-whole-trick]

The rendered colour of a translucent surface is a linear interpolation between
its own tint and whatever is behind it:

```text
composite = α · tint + (1 − α) · backdrop
```

Browsers perform this compositing in the device colour space, on the encoded
channel values, for an ordinary `background-color` with alpha over opaque
content. The backdrop is unknown and unbounded. But `α` and `tint` are both
fixed by us, so the composite is not unbounded at all: it is confined to the
segment between two extremes.

* The **darkest** possible composite occurs when the backdrop is black:
  `composite = α · tint`.
* The **lightest** possible composite occurs when the backdrop is white:
  `composite = α · tint + (1 − α)`.

If the ink clears the floor against *both* extremes, it clears it against every
backdrop that exists. This includes video, a photograph nobody has seen, and a
live camera feed. That is the entire method, and it is why the alpha
is chosen last rather than first: `α` is the smallest value for which both
extremes pass.

### Worked by hand, to show the method [#worked-by-hand-to-show-the-method]

Take a white tint at `α = 0.82`, in the light theme, in encoded sRGB where white
is 255:

| Backdrop       | Composite                 | Encoded |
| -------------- | ------------------------- | ------- |
| Black (0)      | `0.82 × 255 + 0.18 × 0`   | ≈ 209   |
| Mid grey (128) | `0.82 × 255 + 0.18 × 128` | ≈ 232   |
| White (255)    | `0.82 × 255 + 0.18 × 255` | 255     |

The binding case in a light theme is the black backdrop, because that is where
the surface is darkest and dark ink on it has the least to work with. So the
ink's floor is checked against the ≈209 composite, not against white. That is
true even though white is what the surface looks like in every mock.

In a dark theme the polarity flips. The tint is near-black, the ink is light,
and the binding case is the **white** backdrop, where the composite is at its
lightest and light text has the least separation. Both are measured; neither is
assumed from the other.

<Callout title="This table is arithmetic, not a measurement">
  The figures above are worked by hand from a stated input to show the shape of
  the calculation. Every figure this system actually ships is measured in CI by
  `scripts/check-contrast.mts` and rendered by `<ContrastReport>`; none is typed
  by a person. The shipped alphas are in the token table on this page.
</Callout>

### Blur does not help, and cannot be traded against tint [#blur-does-not-help-and-cannot-be-traded-against-tint]

This is the misconception that produces most unreadable glass in the wild.

`backdrop-filter: blur()` is a low-pass filter. It removes **high spatial
frequency** from what is behind the surface. That means texture, edges and
detail. It does not meaningfully change the **local mean luminance**. A
photograph that is uniformly dark is still uniformly dark after a 40px blur; a
photograph with a bright window in one corner still has a bright region there,
merely a softer one.

So blur does two useful things and one thing it is often credited with and does
not do:

* It **does** stop glyph edges competing with backdrop edges, which is a real
  legibility improvement and the reason blur is in the ladder at all.
* It **does** reduce the *variance* of the backdrop under a given piece of text,
  which makes the reading experience calmer.
* It **does not** bound the worst case. Only the tint does that.

The practical consequence: when somebody proposes dropping the alpha to 0.5 and
raising the blur to 40px because it looks better, the answer is no, and the
reason is not taste. That change makes the page slower
([Performance budget](./performance-budget.mdx)) and removes the guarantee.
Blur radius is tuned for feel; alpha is not tuned at all.

### Scrims [#scrims]

A scrim is a layer whose job is to bound the backdrop *before* the material
composites over it. Two kinds, with different rules.

**Flat scrim (rung 5).** A uniform dark wash beneath an `overlay`. Its opacity
is chosen the same way everything else on this page is: it is the minimum at
which the overlay above it, and any text placed directly on the scrim itself,
clears the floor. It is not chosen to look right at 40%.

**Gradient scrim (for text directly over imagery).** Where text has to sit on a
photograph with no material between them, such as a hero, a body diagram or an
onboarding illustration, a gradient scrim runs from the text's edge toward the
middle of the image. One rule matters and it is routinely broken:

> The gradient must reach its full opacity **before** the first text baseline,
> not at it.

A gradient that is still ramping where the text begins gives the first line of
text a different background from the last, so either the first line is
under-protected or the whole scrim is heavier than it needs to be. Ramp early,
hold flat behind the text.

**The rule that follows from both:** text never sits directly on an image. It
sits on a material of rung 3 or higher, or on a scrim. There is no third option
and no exception for "the image is dark anyway", because you do not control the
image after the product ships.

### What the CI job actually does [#what-the-ci-job-actually-does]

`scripts/check-contrast.mts` enumerates the declared pair set from
[Colour roles](../colour/colour-roles.mdx), and for every translucent rung it
additionally enumerates:

1. each ink role allowed on that rung, against the rung's **darkest** composite,
   the rung's tint at its own alpha over black;
2. the same, against the rung's **lightest** composite, the tint over white;
3. both of those in light and in dark.

Each is measured with both contrast models and compared to the published floor
in [Contrast and APCA](../colour/contrast-and-apca.mdx). Where a rung clears the
floor at both extremes it is safe over any backdrop; where it does not the report
below records the shortfall rather than a fallback figure that hid it. The
results:

<ContrastReport scope="materials" />

<Todo>
  Three lines the older version of this section carried were promises the
  pipeline did not keep, and each is a gap rather than a guarantee until the code
  closes it. A translucent rung's border is not measured as a non-text pair, so a
  material boundary clears no floor of its own. Nothing is measured in Display-P3,
  only in sRGB, so a wide-gamut screen sits outside the audited set. And a
  below-floor result fails `contrast:verify`, which runs in the nightly workflow
  rather than in the pull-request check, so a regression is caught the next night
  and not at the commit that introduced it.
</Todo>

## Using it [#using-it]

Three rules, in the order you will need them.

**1. If you cannot name the rung, the surface is not allowed.** An
alpha typed into a component is a safety parameter set by somebody who was not
thinking about safety. Every translucent surface in a product built on opsinjs
comes from the ladder, and the ladder's alphas come from the calculation above.

**2. A health value goes on an opaque rung.** `canvas`, `card` or `raised`. A
number somebody may act on should have a *known* background, not a bounded one.
The single exception is a sticky summary bar repeating a value that also appears
opaquely on the same screen. That exception and its reasoning are in
[Choosing a layer](./choosing-a-layer.mdx).

**3. Never author alpha inline.** Not `rgba()`, not `oklch(… / 0.6)`, not
`opacity: 0.7` on a container. The third is the sneaky one: setting `opacity` on
a parent makes *every* descendant translucent, including the text, and text
whose own alpha is below 1 is in no measured pair at all.

<DoDont>
  <DoDont.Do>
    A sticky `overlay` at the system's own alpha, over a scrolling list, with its
    label in an opaque ink token. The composite is bounded at both ends, both
    ends are measured in both themes, and the bar reads the same over a chart, a
    photograph and a blank page.
  </DoDont.Do>

  <DoDont.Dont>
    The same bar at 45% alpha "because you can see more of the content through
    it", tuned against the one screenshot in the design file. It reads
    beautifully over that image and is unreadable over a dark chart, and nothing
    in the codebase records which image it was tuned against.
  </DoDont.Dont>
</DoDont>

<DoDont>
  <DoDont.Do>
    A hero image with a gradient scrim that reaches full opacity above the first
    baseline, and a headline in an opaque ink token on top of it.
  </DoDont.Do>

  <DoDont.Dont>
    A headline in white with a `text-shadow` over an unmodified photograph. A
    shadow is not a scrim: it improves the edge of a glyph and does nothing for
    the stroke's interior, and it fails completely over a light backdrop.
  </DoDont.Dont>
</DoDont>

### How to check a surface yourself [#how-to-check-a-surface-yourself]

1. Put the surface over pure black. Read the text.
2. Put it over pure white. Read the text.
3. Put it over the busiest photograph you can find, at the smallest text size
   the surface uses.
4. Take a screenshot of step 3 and desaturate it fully.

If any of the four is uncomfortable, the alpha is wrong. If the alpha came from
the ladder, then either the surface is at the wrong rung or you have found a real
bug, which is worth reporting.

## Tokens [#tokens]

<TokenTable scope="material" />

## Accessibility impact [#accessibility-impact]

**What is guaranteed.** Every translucent rung's text is measured against its
real worst-case composite, its own tint at its own alpha over black and over
white, in both themes, rather than against the opaque fallback it never shows.
The figures in the report above are that worst case: where a rung clears the
floor at both extremes it is safe over *any* backdrop, including content that did
not exist when the theme was built, and where one does not the report says so
rather than resting on a fallback that always passed.

**What is yours.**

* Text placed directly on imagery with no material and no scrim. The system
  cannot measure a pair whose background is your photograph.
* Any alpha you introduce, including `opacity` on a container.
* The scrim over a *video*, where the worst case is the worst frame and not the
  poster image.
* Contrast inside imagery itself. A body diagram whose labels sit on the
  illustration is a contrast surface that belongs to whoever drew it.

**The success criteria in play.** SC 1.4.3 for text, SC 1.4.11 for the borders
and any non-text mark on a translucent rung, and SC 1.4.12 indirectly, because
text spacing changes which part of the backdrop each line lands on.

**Two adjacent requirements this floor does not satisfy.** Reduced transparency
is a separate contract with its own page
([Reduced transparency](./reduced-transparency.mdx)), and printing collapses
translucency entirely rather than approximating it
([Print and export](../../accessibility/print-and-export.mdx)). Neither is a
degradation of the floor; both are different ways of meeting it.

<ResearchNote evidence="opinion" date="2026-09-02">
  Choosing the alpha from the worst-case composite rather than from a
  representative backdrop is our judgement. The cheaper alternative is to sample
  the backdrops the product actually uses, measure against those, and accept a
  lighter, prettier material. That is what most systems do, and it is defensible
  when the backdrop set is fixed and small.

  We reject it here because the backdrop set in a consumer health product is
  neither. Users set their own photographs; charts change shape with data; a
  camera feed is arbitrary. A guarantee that holds for the backdrops somebody
  thought of is not a guarantee, and the element at risk is frequently the one
  carrying a measurement.

  What would change our mind: a mechanism for measuring the actual composite at
  runtime and adapting the tint, which browsers do not currently offer in a form
  that is cheap enough to run per frame. If that arrives, the fixed floor becomes
  a fallback rather than the rule.
</ResearchNote>

## Related [#related]

* [The ladder](./the-ladder.mdx) has the six rungs, three of which this page
  applies to.
* [Contrast and APCA](../colour/contrast-and-apca.mdx) has the floor itself, with
  both models, the thresholds, and why both are published.
* [Reduced transparency](./reduced-transparency.mdx) says what happens when the
  reader has asked for none of this.
