Z-index & motion
Ten named stacking levels, five durations, six easing curves and one shorthand.
The last section is the one that matters: because every duration is a token,
tokens.css can switch the entire system to still by redefining five custom
properties, with no priority-raising declaration anywhere.
Z-index
Ten tokens, named after what sits at each level rather than after a number. A component asks
for var(--z-dropdown) and does not need to know or care what the integer is.
The tiers
| Token | Value | What lives here |
|---|---|---|
--z-below | | Decoration behind content — a background flourish, a printed watermark. |
--z-base | | Ordinary page content. The default plane. |
--z-raised | | A card lifting on hover, a focused row above its neighbours. |
--z-dropdown | | Select menus, combobox lists, anything anchored to a control. |
--z-sticky | | Sticky headers and toolbars. The masthead on this page. |
--z-overlay | | The scrim behind a modal. |
--z-modal | | The dialog itself, above its own scrim. |
--z-popover | | Popovers opened from inside a modal, which must clear it. |
--z-toast | | Transient notifications. Above the dialog they report on. |
--z-tooltip | | The top of the world. Tooltips and the skip link. |
Seeing the order
The chips below are in reverse source order: --z-tooltip is the first
element in the markup and --z-below is the last. Without z-index
the last one would paint on top, because later siblings win ties. The tokens are what put them
back in order, so the stacking you see is entirely the doing of the ten values above.
--z-tooltip--z-toast--z-popover--z-modal--z-overlay--z-sticky--z-dropdown--z-raised--z-base--z-belowThe stage sets isolation: isolate, which is why the
--z-below chip is still visible. A negative z-index paints behind
its stacking context's in-flow content but in front of that context's own background —
without the isolation the chip would slide behind the page itself and vanish. That single
declaration is the cheapest way to give a component a private stacking floor.
Why the gaps are so large
The first three tiers sit close together and then the scale jumps by two orders of magnitude
to --z-dropdown, after which every tier is a round hundred apart. The gaps are
the feature. Inserting a new tier between two existing ones must never require renumbering the
ones above it, because renumbering is how a stacking order quietly breaks in a component
nobody thought to retest.
The low block is deliberately tight because everything in it is within a component:
a raised card competes with its own siblings, not with the application shell. From
--z-dropdown upward, every tier is application-global and the ordering between
them is a product decision, so those get room.
A z-index only competes inside its own stacking context. If a component is
trapped in one, no token can rescue it — --z-tooltip inside a stacking
context whose own level is --z-base still paints below a
--z-raised sibling of that context. Raising the number does nothing, which is
exactly why it looks like a token bug when it is not.
Things that create a stacking context and routinely surprise people:
transform with any value other than none,
filter, backdrop-filter, opacity below
1, position: fixed, isolation: isolate,
will-change on any of the above, and contain: paint. An
opacity fade applied to a card is enough to trap every dropdown inside it.
The fix is structural, not numeric: move the escaping element out of the context, or use
the top layer — dialog.showModal() and the popover
attribute both escape stacking contexts entirely and ignore z-index
altogether.
Duration
Five steps. The scale is short on purpose: a system with twelve durations has eleven arguments waiting to happen, and nobody can tell 180ms from 200ms in a running interface.
| Token | Value | Use |
|---|---|---|
--duration-instant | | State feedback that must not feel delayed — an active press, a checkbox tick. |
--duration-fast | | The default. Hover and focus colour changes, small property tweens. |
--duration-normal | | Something appearing or disappearing: a dropdown, a disclosure panel. |
--duration-slow | | Large surfaces in motion. A dialog, a drawer, a sheet. |
--duration-slower | | Full-screen or page-level transitions. Rare, and it should stay rare. |
Compare them
Every row below runs the same distance with the same easing
(--ease-standard) and differs only in duration. Tick the box to send them, untick
to send them back.
--duration-instant
--duration-fast
--duration-normal
--duration-slow
--duration-slower
That control is a plain checkbox and a :checked sibling selector. No script is
involved, which also means it is a fair test of the reduced-motion behaviour further down: if
your system asks for reduced motion, those dots teleport.
Easing
Six curves. --ease-standard is the house default and is what
--transition-base carries; the rest exist for the cases where direction of travel
carries meaning.
| Token | Value | Curve | Use |
|---|---|---|---|
--ease-linear |
|
Continuous motion with no start or end — a spinner, a progress bar. | |
--ease-standard |
|
The default for everything. Leaves quickly, arrives softly. | |
--ease-in |
|
Something leaving the screen. Accelerating away reads as departure. | |
--ease-out |
|
Something arriving. Decelerating into place reads as landing. | |
--ease-in-out |
|
Motion that starts and ends on screen. A drawer sliding between two rest states. | |
--ease-spring |
|
Overshoots and settles. Confirmation and delight only, never navigation. |
Each curve is drawn from its own control points, in currentColor, with the unit
square outlined behind it: horizontal is time, vertical is progress. The spring curve leaves
the square at the top because it genuinely passes its target value before coming back —
which is also why it must never be used on anything positional that a user is trying to
hit.
Compare them
Same distance, same duration (--duration-slow, slow enough to see the
shape), differing only in easing.
--ease-linear
--ease-standard
--ease-in
--ease-out
--ease-in-out
--ease-spring
The shorthand
--transition-base packs the two house defaults into one token:
--transition-base: var(--duration-fast) var(--ease-standard);It resolves to . It is a
fragment of the transition shorthand, not a whole value, so it always
follows a property name:
/* One property. */
transition: background-color var(--transition-base);
/* Several, each named. */
transition: border-color var(--transition-base), background-color var(--transition-base);Naming the properties is the rule, not a style preference.
transition: all var(--transition-base) animates properties you never intended,
including ones a future browser adds, and it forces the engine to check every animatable
property on every state change. base.css names them everywhere, and that is
greppable.
Reach past --transition-base when the motion is not a small state change:
pair --duration-normal with --ease-out for something arriving,
--duration-normal with --ease-in for something leaving. The
shorthand exists to make the common case short, not to make the uncommon case wrong.
Reduced motion
This is the most important section on the page, and it is four lines of CSS.
tokens.css redefines every duration token to 1ms inside a
prefers-reduced-motion query. Nothing else in the package mentions reduced
motion. Because every transition and animation in the system is built on these tokens, the
whole thing goes still at once.
@media (prefers-reduced-motion: reduce) {
:root {
--duration-instant: 1ms;
--duration-fast: 1ms;
--duration-normal: 1ms;
--duration-slow: 1ms;
--duration-slower: 1ms;
}
}Note what is not there. No * selector, no
animation-duration catch-all, and no priority-raising declaration anywhere. The
usual reduced-motion block that most systems ship — a universal selector pinning
animation-duration and transition-duration to a hundredth of a
millisecond, with a priority-raising flag on every declaration so nothing can answer back
— is a sledgehammer that also breaks any deliberate animation a consuming application
wrote, with no way to opt out. reset.css deliberately does not carry it. The
token redefinition reaches the same destination by changing an input rather than overruling
an output.
The one thing reset.css does gate directly is scroll-behavior,
because smooth scrolling is not driven by a duration token and there is nothing to collapse.
It is wrapped in @media (prefers-reduced-motion: no-preference) instead.
How to test it
Set the preference and reload. The demos above are the test: with reduced motion on, every dot changes position with no visible travel.
| Where | Setting |
|---|---|
| Windows | Settings → Accessibility → Visual effects → Animation effects, off. |
| macOS | System Settings → Accessibility → Display → Reduce motion, on. |
| Android | Settings → Accessibility → Remove animations, on. |
| iOS / iPadOS | Settings → Accessibility → Motion → Reduce Motion, on. |
| Devtools | Chrome and Edge: the Rendering panel, Emulate CSS media feature prefers-reduced-motion. Firefox: the Inspector's accessibility toolbar. No reload or OS change needed — this is the fast way. |
An animation that hardcodes its own duration keeps running. The media query redefines tokens; it cannot reach a literal.
/* Goes still under reduced motion. */
.panel { transition: opacity var(--duration-normal) var(--ease-out); }
/* Keeps animating. Nothing in the system can stop it. */
.panel { transition: opacity 250ms ease-out; }
@keyframes pulse { /* … */ }
.badge { animation: pulse 2s infinite; }That is the argument for using the tokens, stated as plainly as it can be stated. A raw
duration is not a style preference and it is not a shortcut — it is an accessibility
regression that no amount of system CSS can undo on your behalf. If a component needs an
animation, give its animation-duration a token, and it inherits the correct
behaviour for free.
Reduced motion is a real setting that real people turn on, including for vestibular disorders where the wrong animation causes nausea rather than annoyance. The five-line block above is the entire contract, and honouring it costs nothing at the call site.