Staggered List Animations Using --i Index Custom Properties
When a menu, card grid, or notification list appears, animating every item at the same instant looks flat; revealing them in a quick cascade reads as intentional and polished. The narrow problem here: produce that staggered entrance with pure CSS by storing each item's position in an --i custom property and multiplying it into animation-delay, so item zero starts immediately, item one a beat later, and so on — with no JavaScript timing loop. This pattern is part of the broader keyframe animation patterns guide and leans on the same custom-property discipline described in the CSS custom properties architecture guide.
What this technique gives you:
- One
@keyframesrule shared by every item. - A per-item delay derived from a single
--itoken. - Zero runtime JavaScript — the index is static markup or
nth-child. - A clean reduced-motion path that reveals everything at once.
Why a Custom Property Beats Hand-Written Delays
The obvious approach is to write animation-delay by hand for each child: :nth-child(1) { animation-delay: 0ms }, :nth-child(2) { animation-delay: 60ms }, and so on. That works for three items and rots immediately afterwards — every list length needs a new block of rules, and changing the rhythm means editing every line. Promoting the index to a custom property collapses all of that to one declaration: animation-delay: calc(var(--i) * 60ms). The cascade computes the right delay per element, and tuning the whole sequence is a single 60ms edit.
The reason to prefer CSS over a JavaScript stagger (such as a library that sets setTimeout per node) is the same as for any entrance animation: the work is declarative, ships no bytes of script, and the browser schedules it on the compositor. The accessibility tradeoff is the one to watch — a stagger is additive latency. Each item's content is invisible until its delay elapses, so a 12-item list at 80ms each hides the last item for nearly a second. Keep per-item steps small, cap total duration, and always collapse the stagger when the user prefers reduced motion. A stagger is a flourish, never a gate on reading content.
Complete Working Implementation
Each <li> carries its index inline as style="--i:N". A single keyframe rule fades-and-slides each item up; the per-item delay comes entirely from --i. The resting state (opacity: 0) plus animation-fill-mode: both keeps items invisible during their delay so nothing flashes before its turn.
<ul class="stagger">
<li style="--i:0">Dashboard</li>
<li style="--i:1">Projects</li>
<li style="--i:2">Team</li>
<li style="--i:3">Reports</li>
<li style="--i:4">Settings</li>
</ul>
.stagger {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.stagger li {
/* Resting (hidden) state. fill-mode: both applies the `from`
keyframe during the delay so the item is invisible until its turn. */
opacity: 0;
/* One shared keyframe; the delay is the only per-item difference. */
animation: item-enter 0.4s cubic-bezier(0.2, 0, 0, 1) both;
/* `--i` is multiplied by a step. Multiply by ms or calc fails. */
animation-delay: calc(var(--i) * 60ms);
}
@keyframes item-enter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Reduced motion: no cascade, no slide. Everything is present at once. */
@media (prefers-reduced-motion: reduce) {
.stagger li {
animation: none;
opacity: 1;
}
}
Note animation-fill-mode: both (the trailing both in the shorthand). Without it the item would render at full opacity during its delay and only snap to hidden when the animation finally begins — producing a flash. both applies the from state forward across the delay and holds the to state after, so the item is cleanly hidden, then revealed.
The Key Technique: Multiplying a Unitless Token by a Time Unit
var(--i) resolves to a plain number like 2. A number is not a duration, so you cannot drop it straight into animation-delay. The bridge is calc(): calc(var(--i) * 60ms) multiplies the unitless index by a time value, and the multiplication of <number> * <time> yields a valid <time>. This is the single rule everything else hangs on. The same construction lets you derive the stagger step from a design token — calc(var(--i) * var(--stagger-step, 60ms)) — so the rhythm is themeable, exactly the token-driven approach in the CSS custom properties architecture guide. The common failure mode is forgetting the unit (calc(var(--i) * 60)), which produces an invalid <time> and the browser silently discards the delay, so every item fires at once.
Variation: Auto-Indexing with nth-child (No Inline Style)
If you cannot edit the markup to add style="--i:N" — for instance the list comes from a CMS — derive the index in CSS with nth-child. It is more verbose but keeps the HTML clean, and you can still share the single @keyframes rule.
.stagger li { --i: 0; }
.stagger li:nth-child(2) { --i: 1; }
.stagger li:nth-child(3) { --i: 2; }
.stagger li:nth-child(4) { --i: 3; }
.stagger li:nth-child(5) { --i: 4; }
.stagger li:nth-child(6) { --i: 5; }
/* Cap the cascade so a long list never hides its tail for too long. */
.stagger li {
animation-delay: min(calc(var(--i) * 60ms), 360ms);
}
Wrapping the delay in min(…, 360ms) clamps the total so even a 30-item list finishes its entrance in well under half a second, which keeps the flourish from becoming a latency problem.
Browser Support
The whole pattern is broadly supported: custom properties, calc() mixing numbers and time units, @keyframes, and animation-fill-mode all work in Chrome/Edge 49+, Safari 10+, and Firefox 31+. The min() clamp in the variation needs Chrome/Edge 79+, Safari 11.1+, and Firefox 75+ — if you must support older engines, drop the min() and keep the plain calc() delay, accepting that very long lists stagger further. No @supports guard is necessary for the core technique.
FAQ
How do I set the --i index without JavaScript?
Write the index inline with style="--i:0", --i:1 and so on in the HTML, or generate it with nth-child rules in CSS. Both are static and need no script at runtime.
Why does calc(var(--i) * 60ms) not work in animation-delay?
A bare custom property is an unitless token until you multiply it by a time unit. calc(var(--i) * 60ms) is correct; calc(var(--i) * 60) without the ms unit produces an invalid value and the delay is ignored.
How do I keep items hidden before their delay fires?
Set the resting hidden state on the element itself (opacity: 0) and use animation-fill-mode: both so the from keyframe is applied during the delay, before the animation starts.
Does a long stagger hurt accessibility?
It can. Long cascades delay content for everyone and can trigger vestibular discomfort, so cap the total at a few hundred milliseconds and collapse the stagger to zero under prefers-reduced-motion.
Related
- Keyframe Animation Patterns — the parent guide to repeating and entrance animations.
- CSS-Only Loading Spinners and Skeletons — sibling pattern; skeleton rows often use the same stagger.
- CSS Custom Properties Architecture — how token-driven values like the stagger step are structured.
- Reducing Motion Preferences in CSS — collapsing the cascade for reduced-motion users.
- Fluid Space Scale with Clamp — the same multiply-a-token idea applied to responsive spacing.
Related articles
More pages in the same section.