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:
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:
| Preference | What changes | Implemented? |
|---|---|---|
prefers-reduced-transparency: reduce | translucent rungs collapse to their opaque fallback, drop their blur and reset their saturation | yes, in the token layer, for all six rungs |
prefers-contrast: more | borders strengthen and tints deepen; blur is unaffected | no. 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: active | the palette is replaced by the operating system's; the ladder survives as borders alone | partly. 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
| Token | What it controls | Used 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-alpha1 | Rung 0 (canvas): how opaque that tint is. | surface |
| --opsin-material-canvas-blur0px | Rung 0 (canvas): backdrop blur radius. | surface |
| --opsin-material-canvas-saturation1 | Rung 0 (canvas): backdrop saturation multiplier. | surface |
| --opsin-material-canvas-bordernone | Rung 0 (canvas): the boundary. | surface |
| --opsin-material-canvas-shadownone | Rung 0 (canvas): the shadow that separates it from what is behind. | surface |
| --opsin-material-canvas-scrim0 | Rung 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-alpha1 | Rung 0.5 (inset): how opaque that tint is. | surface |
| --opsin-material-inset-blur0px | Rung 0.5 (inset): backdrop blur radius. | surface |
| --opsin-material-inset-saturation1 | Rung 0.5 (inset): backdrop saturation multiplier. | surface |
| --opsin-material-inset-bordernone | Rung 0.5 (inset): the boundary. | surface |
| --opsin-material-inset-shadownone | Rung 0.5 (inset): the shadow that separates it from what is behind. | surface |
| --opsin-material-inset-scrim0 | Rung 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-alpha1 | Rung 1 (card): how opaque that tint is. | surface |
| --opsin-material-card-blur0px | Rung 1 (card): backdrop blur radius. | surface |
| --opsin-material-card-saturation1 | Rung 1 (card): backdrop saturation multiplier. | surface |
| --opsin-material-card-bordervar(--opsin-neutral-200) | Rung 1 (card): the boundary. | surface |
| --opsin-material-card-shadownone | Rung 1 (card): the shadow that separates it from what is behind. | surface |
| --opsin-material-card-scrim0 | Rung 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-alpha1 | Rung 2 (raised): how opaque that tint is. | surface |
| --opsin-material-raised-blur0px | Rung 2 (raised): backdrop blur radius. | surface |
| --opsin-material-raised-saturation1 | Rung 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-scrim0 | Rung 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.93 | Rung 3 (sheet): how opaque that tint is. | surface |
| --opsin-material-sheet-blur20px | Rung 3 (sheet): backdrop blur radius. | surface |
| --opsin-material-sheet-saturation1.6 | Rung 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.93 | Rung 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.74 | Rung 4 (overlay): how opaque that tint is. | surface |
| --opsin-material-overlay-blur28px | Rung 4 (overlay): backdrop blur radius. | surface |
| --opsin-material-overlay-saturation1.8 | Rung 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.74 | Rung 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.44 | Rung 5 (scrim): how opaque that tint is. | surface |
| --opsin-material-scrim-blur2px | Rung 5 (scrim): backdrop blur radius. | surface |
| --opsin-material-scrim-saturation1 | Rung 5 (scrim): backdrop saturation multiplier. | surface |
| --opsin-material-scrim-bordernone | Rung 5 (scrim): the boundary. | surface |
| --opsin-material-scrim-shadownone | Rung 5 (scrim): the shadow that separates it from what is behind. | surface |
| --opsin-material-scrim-scrim0.44 | Rung 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.
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
Related
- 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.
Colour tokens
The generated reference for every colour token. It carries the naming grammar, both themes, both gamuts, and the measured pair each one belongs to.
The ladder
The six named rungs are canvas, card, raised, sheet, overlay and scrim, each with its job, its eight properties and its opaque fallback.