Surface
The primitive behind every panel, sheet and card. It decides how solid or translucent a layer looks, and guarantees that whatever sits on it stays readable.
Preview
surface · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/surface
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Surface } from "@/components/ui/surface"<Surface rung="sheet">Everything the surface holds</Surface>When to use it
Use it when
- Building a layered component at a defined rung: a custom panel, a floating bar or a persistent header.
- Any surface that overlays scrolling content and must stay readable whatever passes beneath it.
- Expressing depth through material rather than shadow, which is how this system shows layering.
Do not use it when
- You need a bounded group of related content with padding and a border, on the page rather than over it.Use
cardinstead. - You are building a bottom sheet with detents and drag.Use
sheetinstead. - You are building a modal that traps focus.Use
dialoginstead. - You are reaching for a fourth composited translucent layer on one screen, which stutters on mid-range Android.Use
cardinstead. - A number a person must read is going on `sheet` or `overlay`. A health value never sits on a translucent rung above `raised`.Use
cardinstead.
Anatomy
SurfaceThe root. Resolves the rung's material properties, carries its shadow, and is position: relative, never isolation: isolate.Controlled byrungSurface.BackdropBlur and saturation over the page. Removed on an opaque rung, under reduced transparency, increased contrast, forced colours, without backdrop-filter.Controlled byopaqueSurface.ScrimThe rung's tint at max(tint-alpha, minimum scrim opacity). On every rung, reachable by no prop, and why text stays readable.Surface.EdgeAn outline in the rung's border colour, widened under increased contrast. Draws nothing where a rung's border token is none.Surface.ContentEverything the surface holds. Last in document order, so it paints above the other three. Carries no padding.Controlled bychildren
Examples
Chrome that content scrolls beneath
rung="overlay" doing the job it is named for. The panel opens already scrolled,
so a dark row sits under the pinned header and the scrim holds the heading
legible over it.
surface-over-scrolling-content · base base · style base-lyraOpen under the product themeThe degraded path, without changing a setting
opaque renders the same fallback that reduced transparency, prefers-contrast: more and a missing backdrop-filter reach. The border, shadow and geometry
stay, because they carry the layering once translucency goes.
surface-opaque-fallback · base base · style base-lyraOpen under the product themeAccessibility
Audited against WCAG 2.2 AA. A source pass over the full rubric and a
rendered pass on /view/base/base-lyra/component/surface. This audit is
author-run, so it is not an independent accessibility review, and clinical
review is still pending. The rendered pass reported zero axe violations, no
target-size hits, no focus-visible findings, and no horizontal scroll at 320px.
The audit changed nothing in the source, because the source already met the
checks: the surface is presentational with no role, no name and no tab stop, its
three decorative layers are empty and aria-hidden, the Backdrop renders only
in the branch where the Scrim is unconditional so no text sits over an unscrimmed
blur, and reduced transparency, prefers-contrast: more, forced-colors: active
and a missing backdrop-filter each reach a defined
opaque fallback that keeps the
border, shadow and geometry. The boundary is an outline, so forced-colors
preserves it where a shadow would be dropped.
Two residual items belong to the token owner, not to this file. Under
prefers-contrast: more the edge widens
from a hairline to an emphasis width but keeps its published
--opsin-material-*-border colour, and several rungs publish a low alpha, so
whether the widened edge clears 3:1 for SC 1.4.11 is unmeasured. Substituting a
solid colour would give canvas and scrim an edge they are specified not to
have, so the fix sits in tokens/material.json, not here. Separately, className
is merged unrestricted, so a caller can put a category tint and a status tint on
one Surface. The component cannot detect that at render time, and the
two colour axes rule and its repair are stated
on the API. No mix occurs on any element the component itself renders.
| Key | Action | Notes |
|---|---|---|
| Tab | Passes through the surface | Surface is presentational, with no role, no focus and no tab stop. |
| Shift+Tab | Passes through the surface | Same in reverse. A Surface never joins the focus order, at any rung. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below 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 |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from SurfaceProps in registry/bases/base/surface.tsx.
rung is required, because no default depth is safe to guess. There is no
blur, opacity, tint, radius or as prop, and no contentWeight, because
no large-text scrim floor has been measured. A rung outside the six is refused.
Development warns and the children still render, with no material. className
is merged unrestricted, so the two colour axes
rule applies in full.
Related
- Card is a bounded content group with padding and a border, sitting on the page rather than over it.
- Sheet is a Surface at the sheet rung with detents, drag and dismissal.
- Dialog is a Surface at the sheet rung, under a scrim, with focus management.
- Skeleton is often placed on a Surface, where reduced transparency and reduced motion interact.