Skip to content
RU
Scale
Density
Theme
Accent

Foundations

Elevation and surfaces

Depth is carried by the order of lightness. A shadow means one thing here — "it floats above and is about to disappear" — and it has two carriers: the popover and the modal.

The component
Run #4127
worldgen-01
A card on a panel: a step of surface, not one shadow
Markup
<div class="inst-panel">
  <div class="inst-panel-header">
    <span class="inst-panel-title">Run #4127</span>
  </div>
  <div class="inst-panel-body inst-stack">
    <div class="inst-card">
      <div class="inst-card-title">worldgen-01</div>
      <div class="inst-card-sub">A card on a panel: a step of surface, not one shadow</div>
    </div>
    <input class="inst-input" type="text" value="terrain_chunk_04">
  </div>
</div>

Contract

Depth is carried by the surface. A shadow means "it floats above and is about to disappear" and belongs to three components.

.my-panel {
  background: var(--surface-raised);
  border: var(--hairline) solid var(--border);
}
What Required Why
A surface from the semantics yes Four steps named by work rather than by number: the page, the recess, the raised one, the overlay
A shadow only on something popping up yes A popover, a modal, a notification. There is no fourth carrier of a shadow in the library
A CanvasText border in forced-colors yes The surfaces collapse in that mode, and without a border a block merges with the background
A z-index from the vocabulary yes Four tokens. A number invented in place works until the first larger number of somebody else's

Accessibility

Depth not by colour alone Every step of surface comes with a border or a change of rhythm: telling the stack apart does not require telling lightnesses apart
The contrast of the steps Neighbouring surfaces are checked as a pair in all five themes — otherwise a field on a panel disappears
A shadow does not carry meaning alone An overlay is recognised by its role and behaviour (popover, <dialog>) rather than by a shadow. In forced-colours mode the shadow disappears, and nothing breaks
Scrolling under a modal It stops: showModal() makes the background inert to clicks but not to the wheel, and the user lost the place they were coming back to
Reduced motion The appearance of an overlay collapses to 0.01ms but is not switched off — the state machines listening for transitionend keep working. See motion

Anatomy

Declared in one place so that it does not drift across components.

Token Value Who
--z-sticky 10 The sticky head of a table, the action bar of a form
--z-popover 100 A popover, a menu, a tooltip
--z-modal 1000 A modal, a drawer

A popover and a modal built on the Popover API and <dialog> live in the browser's top layer, where z-index no longer decides. The tokens stay for what does not get the top layer — and for an application that needs to fit into the same order.

Scale

They read as a stack from the bottom up. A step is chosen by the role of the region rather than by a wanted brightness.

Token Step Who carries it
--surface-sunken The bottom A track, the head of a table, a block of code, the body of a step, the background of a side column
--surface-page The page body, the background of the working area
--surface-raised Raised A panel, a card, the header of the shell, the action bar of a form, <kbd>
--surface-overlay The overlay A popover, a menu, a tooltip, a modal, a drawer

The fifth role stands outside the stack, because it is not about depth but about input:

Token Work
--surface-field The recess under a field. A role of its own rather than a reuse of raised: while a field was painted the colour of a panel, its only boundary was a border at 1.31:1 — that is, a field on a panel was not visible at all

Containment

A region inside a region recedes by one film, at any depth. The film is --surface-recessed — the same one a control recesses by — and it is counted from whatever lies underneath rather than being a step of the ramp, which is what makes it work at the second nesting and the third.

An absolute step cannot: it knows one depth and runs out. Measured before the rule was general, a card inside a card and a panel inside a panel both sat at a step of exactly 0.000, with one hairline to tell them apart. Films composite in paint, so three of them on white measure 232 · 218 · 206 and the depth of a nesting shows through the stack of elements rather than through the strength of one fill.

A region takes at most one film. A support region asks for the same film, and one that is already nested still paints one — so rank's ground lever bites at depth 0 only, and a support region deeper in shows its rank in its title and its missing edge. See rank.

The inset steps with the ground too. A nested region insets one rung tighter than a free-standing one, which also settles a figure that was always slightly wrong: a card inside a panel used to inset 16 where the panel inset 12, so the child was roomier than its parent. Nested content recedes, and an inset that grew on the way in was the one place the kit said otherwise.

The corner steps with the ground. A nested region takes --radius-md where a free-standing one takes --radius-lg, because nested radii have to decrease or the inner shape reads as bulging out of the outer one. One rung and not one per level: at a third nesting the next step down would be a control's corner on a full-width block. After the first step the ground carries depth on its own.

The corner belongs to depth and not to rank, and that is a collision rather than a preference. Rank is a screen-level choice, so a lead card can sit inside a support panel — and were the corner rank's to set, that card would carry the larger radius inside a container carrying the smaller one and bulge out of it. Depth cannot collide with itself: a nested region is inside its container by definition, so a step down is always available and always right.

In a dark theme the distance between neighbouring steps is wider than in a light one: the dark end of the ramp is harder to tell apart, and a mirrored light arrangement would give four identical black rectangles. In the dark-soft theme the whole stack rises by one step of the ramp — four surfaces are overridden, and the rest of the semantics does not know the theme has changed. There is no room above that: the next step of the ramp no longer holds text. The light end behaves in exactly the same way, where raised and overlay both sit on --n-0.

States over a surface

Hover and selection are alpha. An opaque fill would keep them from adding up: a hover over a selected row would disappear.

Token Work
--surface-hover Hover
--surface-active Press
--surface-selected A selected row

Variants

Both are functional. Both are declared entire in the tokens — a component substitutes a ready value and does not assemble a shadow itself.

Token Value Who carries it
--shadow-popover 0 4px 12px -2px far + 0 2px 4px -2px near A popover, a menu, a tooltip
--shadow-modal 0 16px 40px -8px far + 0 4px 10px -4px near A modal, a drawer

Every shadow has two layers: the far one gives the distance to the background, the near one the contact of the edge. In one layer it looks either glued down or floating with no support.

The colour of a shadow is kept separate, because light-dark() accepts only a <color> and a whole shadow value does not pass through it:

Token Light Dark
--shadow-color-near oklch(0 0 0 / 0.06) oklch(0 0 0 / 0.30)
--shadow-color-far oklch(0 0 0 / 0.10) oklch(0 0 0 / 0.45)

In a dark theme a shadow is denser. A weak shadow on a dark background is invisible, and a light shadow is a glow already, and glows are forbidden.

The rule

A shadow is given only to what appeared on an action and will disappear on Escape. Everything that stands on the screen permanently is parted by a surface and a border. A card gets no shadow — it gets a surface.

Element Shadow
A panel, a card, a table, an action bar None
A popover, a menu, a tooltip --shadow-popover
A modal, a drawer --shadow-modal
Everything else None, and no new recipient is started

The ground

Token Light Dark
--scrim oklch(0 0 0 / 0.32) oklch(0 0 0 / 0.58)

In a dark theme the ground is denser: on a light page a dimming creates contrast by itself, on a dark one it has to be added. This is a dimming, not a blur — gradients, glows and blurs are forbidden entirely.

The ground is put on the ::backdrop of a native <dialog>, so the top layer, the focus trap and closing on Escape come from the platform rather than from the library.

Rules

What to take and what not to

Use Take instead
A step of surface for permanent structure A shadow on a card or a panel — a shadow means "temporary". Structure is built by the card and the panel
--shadow-popover for what will disappear on Escape A shadow "for volume" — it has no recipient other than the popover
--shadow-modal + --scrim for what blocks the screen A popover instead of a modal when a decision is obligatory — take the modal
--surface-field under input --surface-raised under input — the field will merge with the panel, see the field
Blurring the background under an overlay Blurs are not used — the dimming is done by --scrim

API

Tokens
NameValueWhere from
token
--surface-sunkenlight-dark(var(--n-2), var(--n-14))base +1: theme
--surface-pagelight-dark(var(--n-1), var(--n-13))base +1: theme
--surface-raisedlight-dark(var(--n-0), var(--n-12))base +1: theme
--surface-overlaylight-dark(var(--n-0), var(--n-11))base +1: theme
--surface-fieldvar(--surface-raised)one across every cell
--surface-hoverlight-dark(oklch(0 0 0 / 0.035), oklch(1 0 0 / 0.045))one across every cell
--surface-activelight-dark(oklch(0 0 0 / 0.065), oklch(1 0 0 / 0.080))one across every cell
--surface-selectedcolor-mix(in oklab, var(--a-4) 14%, transparent)one across every cell
--shadow-popover0 4px 12px -2px var(--shadow-color-far), 0 2px 4px -2px var(--shadow-color-near)one across every cell
--shadow-modal0 16px 40px -8px var(--shadow-color-far), 0 4px 10px -4px var(--shadow-color-near)one across every cell
--shadow-color-nearlight-dark(oklch(0 0 0 / 0.06), oklch(0 0 0 / 0.30))one across every cell
--shadow-color-farlight-dark(oklch(0 0 0 / 0.10), oklch(0 0 0 / 0.45))one across every cell
--scrimlight-dark(oklch(0 0 0 / 0.32), oklch(0 0 0 / 0.58))one across every cell
--z-sticky10one across every cell
--z-popover100one across every cell
--z-modal1000one across every cell
--borderlight-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11))one across every cell
--border-subtlelight-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06))one across every cell
--hairline1pxbase +1: screen

Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument