---
title: "Material tokens"
description: "The generated reference for all six rungs covers eight properties each, both themes, and the preference overrides that replace them."
url: "https://opsinjs.pensievelabs.org/foundations/materials/tokens"
source: "https://opsinjs.pensievelabs.org/foundations/materials/tokens.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["material tokens", "opsin-material", "surface tokens", "blur token", "scrim token"]
---

> 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]

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](./the-ladder.mdx). For which
one to use, [Choosing a layer](./choosing-a-layer.mdx). For why the alphas are
what they are, [The contrast floor](./the-contrast-floor.mdx). This page is the
list.

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

### The naming grammar [#the-naming-grammar]

```text
--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](./the-ladder.mdx) 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 [#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](./reduced-transparency.mdx)).
   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 [#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 [#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](../../components/surface.mdx) 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](../../handbook/contributing/contributing-tokens.mdx).

## Tokens [#tokens]

<TokenTable scope="material" />

## Accessibility impact [#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:

<ContrastReport scope="materials" />

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-contrast-floor.mdx). 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.

## Related [#related]

* [The ladder](./the-ladder.mdx) says what each rung is for.
* [The contrast floor](./the-contrast-floor.mdx) is where the alphas come from.
* [Generated token reference](../../reference/generated/tokens.mdx) has the same
  data for the whole system, not just materials.
