opsinjs
FoundationsToken familiesMaterials

Material tokens

The generated reference for all six rungs covers eight properties each, both themes, and the preference overrides that replace them.

Overview

Six rungs, eight properties each, two themes. That is forty-eight declarations in the base layer plus the preference overrides, and every one of them is generated from tokens/material.json by scripts/build-tokens.mts. Nothing here is typed by a person and pnpm check:generated fails the build if the committed output and a fresh regeneration disagree.

For what the rungs mean, start at The ladder. For which one to use, Choosing a layer. For why the alphas are what they are, The contrast floor. This page is the list.

How it works

The naming grammar

--opsin-material-<rung>-tint         backing colour, without its alpha
--opsin-material-<rung>-tint-alpha   that colour's alpha, 0..1
--opsin-material-<rung>-blur         backdrop-filter blur radius
--opsin-material-<rung>-saturation   backdrop-filter saturation multiplier
--opsin-material-<rung>-border       hairline colour, or `none`
--opsin-material-<rung>-shadow       drop shadow, or `none`
--opsin-material-<rung>-scrim        minimum scrim opacity for text to clear the floor
--opsin-material-<rung>-opaque       the fallback when translucency is unavailable

<rung> ∈ canvas · card · raised · sheet · overlay · scrim

The name is what appears in CSS, and the ordinal appears nowhere. --opsin-material-sheet-blur, never --opsin-material-3-blur. The numbers on The ladder are the ordering and nothing addresses a rung by one, because a number invites arithmetic such as "one more than a card", and the ladder is not arithmetic: each rung answers a different question about what is behind it. The rule is stated in tokens/material.json itself, which is where the emitter reads it.

tint and tint-alpha are two properties rather than one colour because the alpha is the safety parameter: it is the number the contrast floor is calculated from, and it has to be readable on its own by a component and by scripts/check-contrast.mts.

There is no ninth property and no per-rung variant. A rung is those eight declarations applied together.

What the generator emits

  1. A :root block with all six rungs for the light theme.
  2. A .dark block overriding the rungs that differ. In dark, the translucent rungs' tints are near-black rather than near-white, and the borders flip from dark-at-low-alpha to white-at-low-alpha, because a dark border on a dark surface is invisible.
  3. A @media (prefers-reduced-transparency: reduce) block collapsing every rung to its opaque fallback (Reduced transparency). All six are emitted, not only the three that are translucent: for canvas, card and raised the declarations repeat the values above and the block is a no-op, and what that buys is a component being able to read var(--opsin-material-<rung>-blur) for any rung and be right, with no conditional and no table of which rungs are translucent this month.

The print stylesheet removes backdrop-filter and box-shadow outright rather than overriding the tokens, because a printed page has no backdrop to filter.

Rungs 0, 1 and 2 reference other tokens

The opaque rungs do not carry literal colours. canvas, card and raised all resolve their tint to a step of the neutral ramp. In light the step is --opsin-neutral-0, and in dark it is 950, 900 and 800 respectively, so the ladder lifts one step at a time where a flat colour would leave the three indistinguishable. Their borders resolve the same way: --opsin-neutral-200 in light, 800 and 700 in dark. canvas is the exception to the exception and has no border at all. Its border token resolves to none, literally, because the page itself has no edge.

This is deliberate: an opaque material is a composition of the neutral palette, not a parallel palette, and a theme change should move the ladder with it. The translucent rungs still resolve a neutral step for their tint, and what they add is the alpha the neutral palette does not have, which is authored on the rung.

Using it

Apply all eight properties together. A rung's blur without its backing tint is the exact failure the contrast floor exists to prevent, and a rung's tint without its border is a surface with no edge under reduced transparency. Surface applies them as a unit and ships, so reach for it before reaching for the custom properties; if you are writing the CSS yourself, the unit is your responsibility.

Reference the token, never the resolved value. A copied oklch(… / 0.82) is a translucent surface outside the ladder: it will not respond to reduced transparency, it will not respond to the theme, and it is in no measured pair.

Do not build a rung out of Tailwind's opacity utilities. bg-white/70 backdrop-blur-xl looks like a material and is not one, for the same three reasons. If a utility class is genuinely more convenient, expose the token as one rather than approximating it.

Do not add a per-component variant. "The sheet on this one screen needs a slightly stronger blur" is how a six-rung ladder becomes a fourteen-value parameter space. If a screen genuinely needs something the ladder does not have, that is intake, not CSS. See Contributing tokens.

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

Each rung is measured with body text on it, in both themes. An opaque rung is measured on its own colour, and a translucent rung against its worst-case composite. The published 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

Three properties of that table matter and are easy to take for granted:

  • A translucent rung is measured as a composite, not as its opaque fallback. check-contrast.mts composites the rung's tint at its per-theme alpha over the darkest and the lightest backdrops the product can produce, --opsin-neutral-950 and --opsin-neutral-0, and measures the ink against each, so the published figures for sheet and overlay are the real worst case rather than the reduced-transparency colour. The method is The contrast floor. The opaque rungs are still measured on their own colour, because that is the only surface they ever show.
  • scrim has no row, and that is correct. Rung 5 exists to remove the page from consideration, so nothing sits on it and there is no text pair to measure. The generator skips it by name rather than by accident.
  • Borders are not measured. A hairline at low alpha that disappears over a particular backdrop is an SC 1.4.11 failure, and it is the one most often missed because it looks correct in the single mock everybody reviewed. Nothing on this site has measured one. That is an open gap, and it is named here rather than left to be inferred from a table that does not contain it.

Under reduced transparency a translucent rung collapses to its opaque fallback, which for sheet and overlay is the card colour, so in that mode their contrast is card's row. The report above measures the translucent case instead, the surface these rungs actually render, which is why sheet and overlay now carry their own composite rows rather than repeating card. scrim is the one rung whose fallback is its own value, because a scrim that became opaque would be a blank screen. That value is a dark wash at a higher alpha.

On this page