opsinjs
FoundationsToken familiesMaterials

The contrast floor

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.

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

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:

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

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

BackdropCompositeEncoded
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 × 255255

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.

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.

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) and removes the guarantee. Blur radius is tuned for feel; alpha is not tuned at all.

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

scripts/check-contrast.mts enumerates the declared pair set from Colour roles, 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. 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:

PairThemeAPCA LcWCAG 2.2Floor
body text on the canvas materiallight104.717.96:1Pass
body text on the canvas materialdark-100.518.00:1Pass
body text on the inset materiallight98.116.32:1Pass
body text on the inset materialdark-100.117.18:1Pass
body text on the card materiallight104.717.96:1Pass
body text on the card materialdark-99.616.32:1Pass
body text on the raised materiallight104.717.96:1Pass
body text on the raised materialdark-97.513.77:1Pass
body text on the sheet material over the darkest backdroplight99.916.76:1Pass
body text on the sheet material over the darkest backdropdark-99.716.47:1Pass
body text on the sheet material over the lightest backdroplight104.717.96:1Pass
body text on the sheet material over the lightest backdropdark-80.86.07:1Pass
body text on the overlay material over the darkest backdroplight85.913.48:1Pass
body text on the overlay material over the darkest backdropdark-99.916.77:1Pass
body text on the overlay material over the lightest backdroplight104.717.96:1Pass
body text on the overlay material over the lightest backdropdark-54.22.69:1Below floor
Not written yet.

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.

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.

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.

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.

Don’t

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.

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.

Don’t

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.

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

TokenWhat it controlsUsed by
--opsin-material-canvas-tintvar(--opsin-neutral-0)Rung 0 (canvas): the tint over what is behind it. The application background. Nothing is behind it, so nothing shows through it.surface
--opsin-material-canvas-tint-alpha1Rung 0 (canvas): how opaque that tint is.surface
--opsin-material-canvas-blur0pxRung 0 (canvas): backdrop blur radius.surface
--opsin-material-canvas-saturation1Rung 0 (canvas): backdrop saturation multiplier.surface
--opsin-material-canvas-bordernoneRung 0 (canvas): the boundary.surface
--opsin-material-canvas-shadownoneRung 0 (canvas): the shadow that separates it from what is behind.surface
--opsin-material-canvas-scrim0Rung 0 (canvas): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-canvas-opaquevar(--opsin-neutral-0)Rung 0 (canvas): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface
--opsin-material-inset-tintvar(--opsin-neutral-100)Rung 0.5 (inset): the tint over what is behind it. The recessed well inside a card: the explanatory note a Callout draws inside the reading it annotates, and the track a RangeBar lays its range along. Its fill sits below the card fill in both themes, so the well reads as carved into the card rather than lifted off it. In light it is neutral-100, one step under the white card, which is where a Callout already sat. In dark it is a value between the page and the card, below the card so the well is a recess and above the page so a Callout used on its own is still visible.callout, range-bar, surface
--opsin-material-inset-tint-alpha1Rung 0.5 (inset): how opaque that tint is.surface
--opsin-material-inset-blur0pxRung 0.5 (inset): backdrop blur radius.surface
--opsin-material-inset-saturation1Rung 0.5 (inset): backdrop saturation multiplier.surface
--opsin-material-inset-bordernoneRung 0.5 (inset): the boundary.surface
--opsin-material-inset-shadownoneRung 0.5 (inset): the shadow that separates it from what is behind.surface
--opsin-material-inset-scrim0Rung 0.5 (inset): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-inset-opaquevar(--opsin-neutral-100)Rung 0.5 (inset): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface
--opsin-material-card-tintvar(--opsin-neutral-0)Rung 1 (card): the tint over what is behind it. The default home for a health value. Opaque, bounded by a line rather than a shadow, and the rung every ResultCard, MetricTile and RangeBar sits on.surface
--opsin-material-card-tint-alpha1Rung 1 (card): how opaque that tint is.surface
--opsin-material-card-blur0pxRung 1 (card): backdrop blur radius.surface
--opsin-material-card-saturation1Rung 1 (card): backdrop saturation multiplier.surface
--opsin-material-card-bordervar(--opsin-neutral-200)Rung 1 (card): the boundary.surface
--opsin-material-card-shadownoneRung 1 (card): the shadow that separates it from what is behind.surface
--opsin-material-card-scrim0Rung 1 (card): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-card-opaquevar(--opsin-neutral-0)Rung 1 (card): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface
--opsin-material-raised-tintvar(--opsin-neutral-0)Rung 2 (raised): the tint over what is behind it. Menus, popovers, tooltips, a dragged card. Still opaque: the shadow, not translucency, is what says 'above'.surface
--opsin-material-raised-tint-alpha1Rung 2 (raised): how opaque that tint is.surface
--opsin-material-raised-blur0pxRung 2 (raised): backdrop blur radius.surface
--opsin-material-raised-saturation1Rung 2 (raised): backdrop saturation multiplier.surface
--opsin-material-raised-bordervar(--opsin-neutral-200)Rung 2 (raised): the boundary.surface
--opsin-material-raised-shadow0 1px 2px oklch(0 0 0 / 0.06), 0 4px 12px oklch(0 0 0 / 0.06)Rung 2 (raised): the shadow that separates it from what is behind.surface
--opsin-material-raised-scrim0Rung 2 (raised): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-raised-opaquevar(--opsin-neutral-0)Rung 2 (raised): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface
--opsin-material-sheet-tintvar(--opsin-neutral-0)Rung 3 (sheet): the tint over what is behind it. A bottom sheet or side panel the reader can dismiss by dragging. Its tint is now near opaque, so the panel lands close to the card colour and a white control inside it no longer floats above a greyer surface. What keeps the covered page recognisable is the scrim around the panel rather than the page reading through the panel, because at this opacity very little of the page shows through the tint.surface
--opsin-material-sheet-tint-alpha0.93Rung 3 (sheet): how opaque that tint is.surface
--opsin-material-sheet-blur20pxRung 3 (sheet): backdrop blur radius.surface
--opsin-material-sheet-saturation1.6Rung 3 (sheet): backdrop saturation multiplier.surface
--opsin-material-sheet-borderoklch(0 0 0 / 0.08)Rung 3 (sheet): the boundary.surface
--opsin-material-sheet-shadow0 -1px 2px oklch(0 0 0 / 0.05)Rung 3 (sheet): the shadow that separates it from what is behind.sheet, surface
--opsin-material-sheet-scrim0.93Rung 3 (sheet): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-sheet-opaquevar(--opsin-neutral-0)Rung 3 (sheet): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface
--opsin-material-overlay-tintvar(--opsin-neutral-0)Rung 4 (overlay): the tint over what is behind it. A pinned toolbar, a tab bar and a floating action bar are all chrome that content scrolls beneath.surface
--opsin-material-overlay-tint-alpha0.74Rung 4 (overlay): how opaque that tint is.surface
--opsin-material-overlay-blur28pxRung 4 (overlay): backdrop blur radius.surface
--opsin-material-overlay-saturation1.8Rung 4 (overlay): backdrop saturation multiplier.surface
--opsin-material-overlay-borderoklch(0 0 0 / 0.10)Rung 4 (overlay): the boundary.surface
--opsin-material-overlay-shadow0 8px 32px oklch(0 0 0 / 0.12)Rung 4 (overlay): the shadow that separates it from what is behind.combobox, menu, popover, select, surface, toast, tooltip
--opsin-material-overlay-scrim0.74Rung 4 (overlay): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-overlay-opaquevar(--opsin-neutral-0)Rung 4 (overlay): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface
--opsin-material-scrim-tintvar(--opsin-neutral-950)Rung 5 (scrim): the tint over what is behind it. The dimming layer behind a modal dialog or a consent sheet. Its job is to remove the page from consideration, not to look like glass.surface
--opsin-material-scrim-tint-alpha0.44Rung 5 (scrim): how opaque that tint is.surface
--opsin-material-scrim-blur2pxRung 5 (scrim): backdrop blur radius.surface
--opsin-material-scrim-saturation1Rung 5 (scrim): backdrop saturation multiplier.surface
--opsin-material-scrim-bordernoneRung 5 (scrim): the boundary.surface
--opsin-material-scrim-shadownoneRung 5 (scrim): the shadow that separates it from what is behind.surface
--opsin-material-scrim-scrim0.44Rung 5 (scrim): the minimum scrim opacity needed for text on this rung to clear the contrast floor.surface
--opsin-material-scrim-opaqueoklch(0.205 0.006 250 / 0.72)Rung 5 (scrim): the opaque substitute used under prefers-reduced-transparency and where backdrop-filter is unsupported.surface

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), and printing collapses translucency entirely rather than approximating it (Print and export). Neither is a degradation of the floor; both are different ways of meeting it.

  • The ladder has the six rungs, three of which this page applies to.
  • Contrast and APCA has the floor itself, with both models, the thresholds, and why both are published.
  • Reduced transparency says what happens when the reader has asked for none of this.

On this page