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.
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
| Name | Value | Where from |
|---|---|---|
| token | ||
--surface-sunken | light-dark(var(--n-2), var(--n-14)) | base +1: theme |
--surface-page | light-dark(var(--n-1), var(--n-13)) | base +1: theme |
--surface-raised | light-dark(var(--n-0), var(--n-12)) | base +1: theme |
--surface-overlay | light-dark(var(--n-0), var(--n-11)) | base +1: theme |
--surface-field | var(--surface-raised) | one across every cell |
--surface-hover | light-dark(oklch(0 0 0 / 0.035), oklch(1 0 0 / 0.045)) | one across every cell |
--surface-active | light-dark(oklch(0 0 0 / 0.065), oklch(1 0 0 / 0.080)) | one across every cell |
--surface-selected | color-mix(in oklab, var(--a-4) 14%, transparent) | one across every cell |
--shadow-popover | 0 4px 12px -2px var(--shadow-color-far), 0 2px 4px -2px var(--shadow-color-near) | one across every cell |
--shadow-modal | 0 16px 40px -8px var(--shadow-color-far), 0 4px 10px -4px var(--shadow-color-near) | one across every cell |
--shadow-color-near | light-dark(oklch(0 0 0 / 0.06), oklch(0 0 0 / 0.30)) | one across every cell |
--shadow-color-far | light-dark(oklch(0 0 0 / 0.10), oklch(0 0 0 / 0.45)) | one across every cell |
--scrim | light-dark(oklch(0 0 0 / 0.32), oklch(0 0 0 / 0.58)) | one across every cell |
--z-sticky | 10 | one across every cell |
--z-popover | 100 | one across every cell |
--z-modal | 1000 | one across every cell |
--border | light-dark(oklch(0 0 0 / 0.12), oklch(1 0 0 / 0.11)) | one across every cell |
--border-subtle | light-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06)) | one across every cell |
--hairline | 1px | base +1: screen |
Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument