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 deliberate. The narrow problem here: produce that staggered entrance in 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, no per-item rule, and no library. This is one of the sequencing techniques in the keyframe animation patterns section of CSS-Only Micro-Interactions & Animations, and it leans on the token discipline described in the CSS custom properties architecture guide.

What this technique gives you:

  • One @keyframes rule shared by every item, regardless of list length.
  • A per-item delay derived from a single --i token and one calc().
  • Zero runtime JavaScript — the index is static markup or nth-child.
  • A reduced-motion path that collapses the cascade to nothing.

Why a custom property beats hand-written delays

The obvious approach is to write animation-delay out by hand for each child: :nth-child(1) { animation-delay: 0ms }, :nth-child(2) { animation-delay: 60ms }, and onwards. That is fine for three items and rots immediately afterwards. Every new list length needs another block of rules, retuning the rhythm means editing every line, and the rules encode a number that is really a position — information the DOM already has. Promoting the index to a custom property collapses all of it into one declaration, animation-delay: calc(var(--i) * 60ms), which is length-agnostic: the cascade computes the right delay per element and the whole sequence retunes from a single 60ms.

Against a JavaScript stagger — a loop of setTimeout calls, or a library that writes inline styles per node — the CSS version wins on more than bytes. It is declarative, it survives with no hydration step, it cannot desynchronise if the main thread stalls during page load, and the animation itself runs off the main thread once composited. What JavaScript still buys you is conditionality: staggering only the items that scrolled into view, or reacting to a list that changes after load. For the pure entrance case, none of that applies.

The accessibility tradeoff is specific and it is the one thing to keep in front of you. A stagger is additive latency. Each item's content is genuinely invisible until its delay elapses, so a twelve-item list at 80ms per step hides the last item for nearly a second — and a screen magnifier user reading at the bottom of the list sees empty space where content should be. Sequential fades also fall squarely inside the motion patterns that trigger vestibular discomfort when they are long or large, discussed in vestibular-safe animation patterns. Keep per-item steps small, cap the total, and collapse the cascade entirely for users who prefer reduced motion. A stagger is a flourish, never a gate on reading.


Complete working implementation

Each <li> carries its index inline as style="--i:N". A single keyframe rule fades and slides each item upward; the per-item delay is the only difference between them, and it comes entirely from --i.

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

The pale bars are dead time — the delay, during which the item exists in layout but must not be visible. The blue bars are the 0.4s animation itself. Note that every blue bar is the same length: the stagger changes only where each animation starts, never how long it runs.

<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 {
  /* The step lives on the parent so one edit retunes the whole cascade,
     and a nested list can override it without touching the item rule. */
  --step: 60ms;
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 8px;
}

.stagger li {
  /* Resting state. Only ever seen if the animation is removed or blocked;
     `backwards` (inside `both`) is what covers the delay window. */
  opacity: 0;

  /* One shared keyframe for every item. The trailing `both` is
     animation-fill-mode: it applies `from` during the delay and holds
     `to` after the animation finishes. */
  animation: item-enter 0.4s cubic-bezier(0.2, 0, 0, 1) both;

  /* The only per-item difference. A unitless index multiplied by a time
     yields a time; drop the unit and the whole declaration is invalid. */
  animation-delay: calc(var(--i, 0) * var(--step));
}

@keyframes item-enter {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Reduced motion: no cascade, no slide, no delay. The list is simply
   present. Note opacity is reset explicitly — with `animation: none`
   the fill-mode no longer supplies the final `to` state. */
@media (prefers-reduced-motion: reduce) {
  .stagger li {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

The var(--i, 0) fallback matters more than it looks: if a template ever emits an item without an inline index, the fallback makes it animate at zero delay instead of dropping the declaration and inheriting whatever was there. Custom properties that fail substitution take the whole declaration with them, so an unguarded var(--i) turns one missing attribute into an unstyled item.


The key technique: multiplying a unitless token by a time unit

var(--i) is not a number in the way a preprocessor variable is. Custom properties hold an arbitrary token stream and are substituted textually at computed-value time, before the property they land in is parsed. So --i: 2 gives you the token 2, and after substitution animation-delay sees the literal text calc(2 * 60ms). Only then is it parsed as a value.

That parse is where the rule lives: in calc(), multiplying a <number> by a <time> produces a <time>, which is exactly what animation-delay accepts. Every part of that sentence is load-bearing. Write calc(var(--i) * 60) and you have multiplied a number by a number, producing a number — invalid for the property, so the declaration is discarded and every item fires at zero delay simultaneously. Write --i: 2ms and try calc(var(--i) * 60ms) and you get time squared, which is equally invalid. The index must stay unitless and the unit must live in the multiplier.

This substitution behaviour also explains why the pattern is so cheap. There is no per-element rule to match, no selector to evaluate per item — one declaration matches every list item, and the computed value simply differs because the substituted token differs. Adding a thousandth item costs one attribute.

Because the index is textual, the browser will happily accept --i: banana and only discover the problem when calc() fails to parse. Registering the property removes that class of bug:

@property --i {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}

Now --i is typed: a non-integer value is rejected at parse time and the property falls back to 0 rather than invalidating the delay, and it has a guaranteed initial value so the var() fallback becomes belt-and-braces. This is the same type-safety argument made in registered properties and type safety, and registration is also what makes a custom property animatable at all, as covered in animating custom properties with @property.


Why fill-mode backwards is mandatory here

Delete both from the animation shorthand and the pattern breaks in a very specific, very visible way. Understanding it requires being precise about what a delay is.

During animation-delay, an element is not animating. The animation exists but has not entered its active phase, so none of the keyframe values apply and the element renders with its ordinary computed styles. With animation-fill-mode: none — the default — an item with --i: 4 therefore sits at whatever the cascade says for 240ms, then snaps into the from keyframe the instant its animation begins. If the resting rule already sets opacity: 0, the item is invisible during the delay by accident and things happen to look right. If it does not, the entire list paints fully visible on load, then items vanish one by one and fade back in — a flicker that looks like a rendering bug and is why so many hand-rolled staggers feel broken.

animation-fill-mode: backwards fixes it by definition: it applies the values of the first relevant keyframe during the delay period, before the animation starts. The item is hidden for 240ms because the from block says opacity: 0, not because a separate resting rule happens to agree. The two are no longer allowed to drift.

forwards is the mirror image and is needed for a different reason. Without it, the moment the animation completes the element reverts to its computed styles — which, in this pattern, are opacity: 0. The list would fade in and then immediately disappear. Since you need the from state held before and the to state held after, the correct value is both, and that is what the shorthand's trailing keyword supplies. The rule of thumb: backwards is what a delay needs, forwards is what a finished one-shot animation needs, and any staggered entrance is both at once.


Variation: auto-indexing with nth-child and a capped total

If you cannot touch the markup — the list comes from a CMS, or a component library owns the template — derive the index in CSS instead. It is more verbose and it has an upper bound, but the HTML stays clean and the single @keyframes rule is unchanged.

/* One rule per position. Anything past the last rule keeps --i: 0 and
   simply enters immediately, which is a safe degradation. */
.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; }
.stagger li:nth-child(7) { --i: 6; }
.stagger li:nth-child(8) { --i: 7; }

/* Hard ceiling on the cascade: no item ever waits longer than 360ms,
   however long the list turns out to be. */
.stagger li {
  animation-delay: min(calc(var(--i) * var(--step)), 360ms);
}

The min() clamp is the more important half. A stagger with no ceiling is a latency bug waiting for a long list: thirty items at 60ms is 1.8 seconds before the last one appears, which is well past the point where users start to think the page is broken. Clamping means the first several items still cascade — the effect the design wanted — while the tail arrives together. If you want the tail to keep some rhythm rather than landing flat, calc(var(--i) * var(--step) / (1 + var(--i) * 0.12)) produces a decelerating stagger that approaches an asymptote instead of stopping dead, at the cost of being much harder to read.

For lists that enter on scroll rather than on load, the index-times-step idea transfers directly but the trigger should not be a load-time delay at all; see scroll-triggered reveal animations. For items inserted into the DOM after load, the delay never fires as intended and @starting-style entry animations is the mechanism you want.


Reduced motion is an obligation, not a nicety

The reduced-motion block in the implementation deserves more than a passing mention because a stagger is one of the few effects where honouring the preference changes the code rather than just softening it.

The wrong fix is animation-duration: 0.01ms, a trick that survives from the days when zeroing a duration could skip the to state. It still runs the delay, so a reduced-motion user waits out the full cascade to see items appear instantly one after another — the sequencing, which is the part likely to cause discomfort, is entirely preserved. The right fix is to remove the animation and restore the final visual state directly, as the @media block above does. Note that it must also reset opacity and transform, because with animation: none there is no fill-mode holding the to values any more.

A middle path is worth knowing: keep a short, uniform opacity fade with no delay and no translation. Fades without positional movement are generally tolerated by users who are sensitive to motion, so animation: item-enter-fade 150ms both with a keyframe that changes only opacity preserves some polish without any travel. The decision matrix for that judgement is in prefers-reduced-motion recipes, and the broader query mechanics are in reducing motion preferences in CSS.


Browser support

The core is old and safe: custom properties, calc() mixing a number with a time, @keyframes, and animation-fill-mode have all been supported across every current engine for years, so no @supports guard is needed. The min() clamp in the variation is likewise widely available; drop it for very old engines and accept a longer cascade. @property registration is the newest piece — Chrome and Edge 85+, Safari 16.4+, Firefox 128+ — and it is purely additive here: an engine that ignores the at-rule still substitutes --i textually and still computes the right delay. prefers-reduced-motion has been supported since Chrome 74, Edge 79, Firefox 63, and Safari 10.1.


FAQ

How do I set the --i index without JavaScript? Write it inline on each element as style="--i:0", style="--i:1" and so on — static markup a template loop can emit at build time — or assign it from a short list of nth-child rules in CSS. Neither needs a script at runtime.

Why must animation-fill-mode include backwards? During its delay an element is not yet animating, so without backwards it renders with its normal computed styles and only snaps to the first keyframe when the animation actually begins. backwards applies that first keyframe throughout the delay, which is what keeps a not-yet-started item hidden instead of flashing at full opacity and then disappearing.

Why does calc(var(--i) * 60) with no unit break the delay? Because var(--i) substitutes a bare number, and a number is not a time. Multiplying it by 60 yields another number, which is invalid for animation-delay, so the declaration is dropped and every item animates at zero delay. Multiply by 60ms so the result carries a time unit.

How long is too long for a stagger? Keep the last item's delay under roughly 300 to 400 milliseconds. Beyond that the cascade stops reading as a single motion and becomes perceptible waiting, and since each item is invisible until its turn, it is real latency on content rather than decoration.


Related articles

More pages in the same section.