Elevation
Six levels, each mapped to a z-index band and a purpose. In light themes elevation is a shadow; in dark themes it is a lighter surface — the token name hides the difference.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Why dark mode is not an inversion
The same four levels in both themes. Notice that the dark column changes background colour and the light column changes shadow — the token name is identical in both.
Elevation as feedback
Hovering raises a card by one level and 1px of translation. Pressing drops it below its resting level. The movement is what makes the surface feel physical.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Level 3 surface
Two shadows: a tight contact shadow and a wide ambient one.
1 · contact
0 4px 8px −4px / 60%2 · ambient
0 12px 20px −6px / 44%3 · surface lift
surface → raised4 · hairline
1px border-subtleEvery level is two shadows plus a surface change plus a hairline. Any one of them alone looks wrong.
- Contact shadowsmall blur, negative spread
Tight and dark, directly under the element. This is what says "resting on" rather than "floating above". Without it, surfaces look like stickers.
- Ambient shadowlarge blur, larger offset
Soft and wide. Communicates height. Growing this one alone is what makes an element seem to fly rather than lift.
- Surface step--ds-surface → --ds-surface-raised
In dark mode this carries almost all of the perceived depth. Roughly 4–6% lightness per level is the range that reads as a step without looking like a different colour.
- Hairline border1px --ds-border-subtle
Defines the edge where the shadow is too soft to. Essential in dark mode, where a shadow against near-black is barely visible.
- Z-index bande3 → z-50
Bound to the level, not chosen per component. This is the discipline that prevents z-index: 9999 from ever appearing in the codebase.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface | — | Level 0–1 background |
| --ds-surface-raised | — | Level 2–3 background |
| --ds-surface-overlay | — | Level 4–5 background |
| --ds-border-subtle | — | The hairline that defines every elevated edge |
| --ds-layer-scrim | — | The dimming layer beneath a modal surface |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e0 | — | Flush. Explicit "no elevation". |
| --shadow-e1 | — | Resting buttons and small controls |
| --shadow-e2 | — | Interactive cards, sticky headers |
| --shadow-e3 | — | Popovers, menus, tooltips, FAB |
| --shadow-e4 | — | Toasts, drawers |
| --shadow-e5 | — | Dialogs, sheets, command palette |
| --shadow-glow | — | Accent emphasis on a focused surface |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | When to use |
|---|---|---|
| Level 0 | z-0 | In-flow content. Table rows, list items, page sections. |
| Level 1 | z-1 | Controls at rest — buttons, switch knobs, kbd. |
| Level 2 | z-10 | Cards that respond to hover, sticky headers, table header rows. |
| Level 3 | z-50 | Popovers, dropdown menus, tooltips, floating action buttons. |
| Level 4 | z-65 – z-75 | Toasts, drawers, split-button menus, scrims. |
| Level 5 | z-75 – z-96 | Dialogs, bottom sheets, the command palette. |
| Inspector | z-200+ | Development overlays only. Never used by product UI. |
z-index: 999999; /* above the other 99999 */Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- A shadow is not a boundary for contrast purposes. Every elevated surface also needs a 1px border or a surface-colour difference that reaches 3:1 against what is behind it.
- In Windows High Contrast Mode shadows are removed entirely. If depth was carried only by shadow, the layering disappears — which is why the hairline border is mandatory.
- The scrim must be dark enough to make background text clearly inactive, and light enough that the user can still see the context. 72% in dark, 42% in light.
Keyboard
| Tab | Focus order must match visual stacking. A dialog at e5 traps focus; nothing behind it should be reachable. |
| Esc | Dismisses the topmost elevated surface, one level at a time. |
Screen readers
- Shadows are never announced. Stacking must be expressed through aria-modal, inert, and DOM order.
- A popover rendered in a portal is far from its trigger in the DOM — wire it with aria-controls and aria-expanded so the relationship survives.
Focus & touch
- Elevated surfaces must receive focus when they open and return it to the trigger when they close. Elevation without focus management is a visual change with no accessibility meaning.
- Elevated surfaces on touch need a larger dismissal area: the scrim itself should be tappable, and a bottom sheet should support drag-to-dismiss as well as a close control.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-modal="true" | Dialogs and drawers at e5 | Marks everything beneath as inert to assistive tech. Visual elevation alone conveys nothing to a screen reader. |
| inert | Background content | The modern equivalent of aria-hidden plus a focus trap; removes the subtree from focus and from the accessibility tree. |
| role="tooltip" / "menu" | e3 surfaces | Non-modal elevated surfaces need a role so their relationship to the trigger is expressed structurally. |
Example usage
1// Elevation is a token, never a literal box-shadow2<Card className="shadow-e2" />3<Popover className="shadow-e3" />4<Dialog className="shadow-e5" />56// Hover moves exactly one level7<div className="shadow-e1 transition-shadow duration-[180ms] hover:shadow-e2" />89// Surface and shadow move together10<div className="bg-overlay shadow-e5 border border-line rounded-2xl" />1112// Z-index comes from the level, not from a guess13const Z = {14 base: 0,15 sticky: 10, // e216 popover: 50, // e317 scrim: 70, // e418 modal: 75, // e519 toast: 90,20 palette: 96,21} as const2223// Modal surfaces render at <body> so no ancestor transform can clip them24createPortal(<Dialog />, document.body)CSS
Two shadows per level in both themes. Note how much darker and tighter the dark-mode values are.
/* DARK — tight, near-black, paired with a lighter surface */
:root, [data-theme='dark'] {
--ds-shadow-1: 0 1px 2px -1px rgb(0 0 0 / 0.60),
0 1px 1px rgb(0 0 0 / 0.32);
--ds-shadow-3: 0 4px 8px -4px rgb(0 0 0 / 0.60),
0 12px 20px -6px rgb(0 0 0 / 0.44);
--ds-shadow-5: 0 16px 32px -8px rgb(0 0 0 / 0.66),
0 40px 72px -16px rgb(0 0 0 / 0.60);
}
/* LIGHT — softer, wider, lower alpha */
[data-theme='light'] {
--ds-shadow-1: 0 1px 2px rgb(16 18 22 / 0.06),
0 1px 3px rgb(16 18 22 / 0.05);
--ds-shadow-3: 0 4px 8px -4px rgb(16 18 22 / 0.09),
0 12px 24px -6px rgb(16 18 22 / 0.09);
--ds-shadow-5: 0 16px 32px -8px rgb(16 18 22 / 0.12),
0 40px 80px -16px rgb(16 18 22 / 0.16);
}
/* High Contrast Mode strips shadows — the hairline is the fallback */
@media (forced-colors: active) {
.elevated { border: 1px solid CanvasText; }
}
/* Animate shadow, never spread. Spread triggers paint on every frame. */
.card {
transition: box-shadow 180ms var(--ease-standard),
transform 180ms var(--ease-standard);
}Professional tips
- If you are unsure which level something needs, ask how the user dismisses it. Nothing to dismiss is e0–e2; click-outside is e3; Escape and a scrim is e4–e5.
- A sticky header only needs its shadow once content has scrolled under it. Toggle the class on an IntersectionObserver sentinel rather than showing it permanently.
- For drag and drop, jump the dragged item to e4 and drop the placeholder to e0. The gap between them is what makes the drag feel like lifting.
- Two shadows always beat one. A single large blur looks like a glow; the tight contact shadow is what sells contact with the surface below.
Performance
- box-shadow is painted on the CPU and repaints whenever the element moves. For anything animating position, prefer a pre-rendered shadow on a composited layer, or animate opacity between two stacked shadows.
- Large blur radii are expensive in proportion to blur², not to element size. An 80px blur on a full-screen dialog is one of the most costly paints in a typical app.
- backdrop-filter combined with a shadow forces two separate compositing passes. It is fine on one dialog and a real problem on fifty list rows.
- will-change: transform on a hover-elevating card promotes it to its own layer, but do not leave it on permanently — each layer costs GPU memory.
Common mistakes
- Elevating a surface without changing its background. In dark mode the shadow is nearly invisible, so the element appears completely flat.
- Rendering a popover inside a container that has a transform. The transform creates a containing block and position: fixed silently stops being fixed.
- Using the same elevation for a hover state and a dragging state, so users cannot tell whether they have picked something up.
- Putting a shadow on a full-width sticky bar. The shadow is only visible at the edges and reads as a rendering artefact.
Real-world recommendations
- Export your z-index bands as a TypeScript const and forbid literal z-index values in review. It eliminates an entire category of bug permanently.
- Test every elevated surface in Windows High Contrast Mode. Shadows vanish, and any layering that depended on them alone disappears with them.
- On mobile, prefer full-screen or edge-anchored surfaces to floating ones. A floating dialog on a 375px screen wastes the margins and puts controls out of thumb reach.
- When a designer asks for "more depth", the fix is usually more contrast between adjacent surface colours, not a bigger shadow. Bigger shadows read as blur, not as height.