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 @keyframes rule shared by every item.
  • A per-item delay derived from a single --i token.
  • 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.

Staggered animation-delay timeline Five horizontal bars stacked vertically; each bar's animation block begins further to the right, offset by an --i multiplied delay along a shared time axis. animation-delay: calc(var(--i) * 60ms) t = 0 time --i:0 --i:1 --i:2 --i:3 --i:4
<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 articles

More pages in the same section.