@arag 0.1.0

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

TokenValueWhat lives here
--z-belowDecoration behind content — a background flourish, a printed watermark.
--z-baseOrdinary page content. The default plane.
--z-raisedA card lifting on hover, a focused row above its neighbours.
--z-dropdownSelect menus, combobox lists, anything anchored to a control.
--z-stickySticky headers and toolbars. The masthead on this page.
--z-overlayThe scrim behind a modal.
--z-modalThe dialog itself, above its own scrim.
--z-popoverPopovers opened from inside a modal, which must clear it.
--z-toastTransient notifications. Above the dialog they report on.
--z-tooltipThe 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-below

The 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.

The limit of any z-index token

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.

TokenValueUse
--duration-instantState feedback that must not feel delayed — an active press, a checkbox tick.
--duration-fastThe default. Hover and focus colour changes, small property tweens.
--duration-normalSomething appearing or disappearing: a dropdown, a disclosure panel.
--duration-slowLarge surfaces in motion. A dialog, a drawer, a sheet.
--duration-slowerFull-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.

TokenValueCurveUse
--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.

When not to use it

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.

WhereSetting
WindowsSettings → Accessibility → Visual effects → Animation effects, off.
macOSSystem Settings → Accessibility → Display → Reduce motion, on.
AndroidSettings → Accessibility → Remove animations, on.
iOS / iPadOSSettings → Accessibility → Motion → Reduce Motion, on.
DevtoolsChrome 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.
What this does not cover

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.