opsinjs
FoundationsToken familiesMaterials

Materials

Six ordered rungs answer what a surface is made of and what is behind it. Each rung has a stated job, an opaque fallback and a contrast floor it has to clear.

Overview

A material is the answer to two questions about a surface: what is it made of, and what can you see through it. Most systems answer the first with a shadow scale and never ask the second. opsinjs answers both together, because in a product where somebody reads a blood pressure off a card, "what is behind the card" is a safety question.

There are six materials. They are an ordered ladder, not a palette, and the order is the amount of separation the surface claims from what is behind it:

Backdrop
  • 0Canvas

    What is the page itself?

    The application background. Nothing is behind it, so nothing shows through it.

  • 1Card

    Is this a distinct piece of content on the page?

    A grouped block of related values, such as a result or a day's readings. Opaque, bordered, no shadow. The workhorse.

  • 2Raised

    Is this temporarily above the page, but not covering it?

    A card that has been lifted: a selected row, a dragged tile, a result being acted on.

  • 3Sheet

    Is this covering the page while leaving it recognisable underneath?

    A bottom sheet or side panel the reader can dismiss. The blur keeps enough of the page visible that they know where they will return to.

  • 4Overlay

    Is this chrome floating over scrolling content?

    A sticky header, a filter bar, a popover. The rung most often misused for content that has to stay readable.

  • 5Scrim

    Am I trying to make everything behind this unusable on purpose?

    The dimming layer behind a modal. Its job is to remove the page, not to be looked at.

The backdrop is generated in CSS, not photographed, so it is the same worst case on every machine. Measured contrast for these pairs is published by pnpm run contrast on foundations/materials/the-contrast-floor. This specimen shows you the problem, and that page gives you the numbers.

Two things make this a foundation rather than a styling convention.

Every translucent rung has a fixed minimum backing tint. The opacity of a translucent material is not a design parameter you tune until it looks right. It is the smallest value at which text on that material clears the contrast floor against the worst possible backdrop. The worst case includes a photograph, a video, or a solid black. That calculation is the point of the whole section and it has its own page: The contrast floor.

Every translucent rung has a documented opaque fallback. Reduced transparency, print and any browser without backdrop-filter all land on the same fallback, and the fallback changes paint only. It never changes layout. Forced colours is specified to land there as well and does not yet; the preference table below says which of the three are real. See Reduced transparency.

The thing this is most often confused with is elevation. Elevation is order: which thing is in front. Material is appearance: what the thing is made of. They correlate, because higher things tend to be more translucent. The two are nonetheless separate contracts, and the stacking rules live in Layering and elevation. A modal is above a card because of its z-index, not because of its blur.

How it works

The properties of a rung

Every rung resolves the same eight custom properties, and there is no ninth:

--opsin-material-<rung>-tint         the backing colour
--opsin-material-<rung>-tint-alpha   how opaque that tint is
--opsin-material-<rung>-blur         the backdrop-filter blur radius
--opsin-material-<rung>-saturation   the backdrop-filter saturation multiplier
--opsin-material-<rung>-border       the boundary, or `none`
--opsin-material-<rung>-shadow       the drop shadow, or `none`
--opsin-material-<rung>-scrim        the minimum scrim opacity text on this rung needs
--opsin-material-<rung>-opaque       the substitute used when translucency is off

<rung> is a name and never an index. The names are canvas, card, raised, sheet, overlay and scrim. A number invites arithmetic, "one more than a card", and the ladder is not arithmetic: each rung answers a different question about what is behind it. The ladder is the six questions and the job description for each, and ADR 0014 is why these are the names.

Constraining a material to one closed set is deliberate, and so is fixing all eight per rung rather than exposing them for tuning. A system where a surface may vary its tint, blur, saturation, border, shadow, inset highlight and noise screen by screen has no ladder at all. It has a parameter space, and every screen ends up somewhere different in it.

The Surface primitive

The way to consume a rung in product code is the Surface component, which takes the rung by name and applies its properties together, so that a surface cannot be assembled half-way. It is built and shipped, which means the API may still move; the ladder it resolves will not.

A stylesheet or a component of your own can apply a rung without Surface. Where that happens, apply the properties together and treat "together" as the rule. A rung's blur without its backing tint is precisely the failure mode the contrast floor exists to prevent.

The three preference queries

The ladder is specified to answer three declared preferences, each with a different answer rather than a shared kill switch. One of the three is implemented:

PreferenceWhat changesImplemented?
prefers-reduced-transparency: reducetranslucent rungs collapse to their opaque fallback, drop their blur and reset their saturationyes, in the token layer, for all six rungs
prefers-contrast: moreborders strengthen and tints deepen; blur is unaffectedno. Surface alone answers it, in its own utilities: it collapses the tint to the opaque fallback and widens the edge, and it does not deepen a tint or change a border colour. See Increased contrast
forced-colors: activethe palette is replaced by the operating system's; the ladder survives as borders alonepartly. Surface draws its edge as an outline, which forced colours keeps and recolours to CanvasText, and it stops rendering the backdrop; the scrim's dimming is still lost, because the system palette replaces the tint with Canvas, and no forced-colours capture exists yet. See Increased contrast

The two partially answered rows stay on the page because the specification is worth publishing even where the answer is incomplete; the third column is there so that nobody plans a translucent sheet under forced colours believing its dimming survives, because Surface's edge now does and the scrim's dimming does not.

Print is a fourth case and behaves like the first, with the additional rule that shadows and blur are removed rather than approximated. That one is real but it is site chrome, not a token: app/globals.css drops backdrop-filter and every shadow on paper, and a consumer's own build has to carry the same rule itself.

Using it

Pick a rung by job, not by looks. "This needs to feel light" is not a reason. "This is persistent chrome that content scrolls under" is. Choosing a layer is the decision table, and it is short on purpose.

Never nest a translucent rung inside another. Two blurs stacked is twice the compositing cost and an unpredictable composite, and the contrast maths no longer has a bounded worst case. A card inside a sheet is an opaque rung.

Do not use more than three composited surfaces at once. This is a performance budget with a real reason behind it and a real way to measure it. Performance budget has both.

Health values go on opaque rungs. A number somebody may act on does not sit on glass. The one narrow exception, and its condition, is on The contrast floor.

Do not author alpha inline. oklch(1 0 0 / 0.6) written into a component is a safety parameter set by somebody who was not thinking about safety. The alpha is a token because it is a floor.

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

Materials are where a design system most often ships an accessibility failure that looks like a feature. Four specific obligations:

  • Text contrast over an unknown backdrop (WCAG 2.2 SC 1.4.3). A translucent surface makes contrast a function of whatever happens to be behind it. The minimum backing tint converts that into a bounded worst case, which is the only way the requirement can be met at all rather than met on the screens somebody happened to test.
  • Non-text contrast (SC 1.4.11). A hairline border at low alpha over a translucent surface is the classic way a card boundary disappears. Borders on translucent rungs are measured against the same worst-case composite as text.
  • Reduced transparency. It is a stated operating-system preference, not a hint, and the response is total: the rung becomes opaque, not slightly less transparent.
  • Motion and vestibular safety. Materials frequently arrive with a transition. Anything that scales or translates a large blurred surface is covered by Reduced motion; the material itself never animates its blur radius.
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
  • The contrast floor has the calculation that fixes every translucent rung's minimum tint, and the CI job that enforces it.
  • Layering and elevation is the stacking contract, which is a different question from what a surface is made of.
  • Colour has the tokens a material's content is coloured from, and the pair rule those tokens obey.

On this page