Staggering Animations with sibling-index() and Custom Properties
Part of Keyframe Architecture & State Mapping in Core CSS Animation Fundamentals.
The problem
A grid of search results should cascade in, each card starting a little after the one before it. The effect is simple to describe and has traditionally been awkward to write. Stylesheets accumulate :nth-child(1) through :nth-child(20) rules with hand-typed delays; templates inject style="animation-delay: 240ms" per item; or a script loops over the elements to set delays, which runs after the first render and occasionally lets items flash in un-animated first.
All three hard-code a relationship — “the delay is proportional to the position” — that CSS can now express directly.
Root cause analysis: CSS had no access to position
A stagger needs one number per element: its position among its siblings. Selectors can match positions with :nth-child(), but until recently no value function could read one, so the number had to come from outside CSS — from the markup, a preprocessor loop or a script.
Two tree-counting functions change that. sibling-index() returns an element’s 1-based position among its siblings, and sibling-count() returns how many siblings there are, as integers usable inside calc(). With them, animation-delay: calc((sibling-index() - 1) * 40ms) is the entire stagger.
Support is newer than most animation features, so the practical pattern pairs them with an index custom property. A --i set inline by the template, or by a small set of :nth-child rules, provides the same number where the functions are not understood.
Step-by-step resolution
The cap is the step most implementations miss. A 40ms step is pleasant for eight items and absurd for sixty: the last card would appear 2.4 seconds after the first, long after the user started scrolling. min() bounds the wait so every item beyond a threshold arrives together at the end of the cascade.
Production code pattern
.results {
--stagger-step: 40ms;
--stagger-cap: 240ms;
}
.results > .card {
--i: 0; /* fallback index, overridden below */
animation: card-in 300ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: min(calc(var(--i) * var(--stagger-step)), var(--stagger-cap));
}
/* Fallback indices for the first few positions; later items share the cap anyway. */
.results > .card:nth-child(2) { --i: 1; }
.results > .card:nth-child(3) { --i: 2; }
.results > .card:nth-child(4) { --i: 3; }
.results > .card:nth-child(5) { --i: 4; }
.results > .card:nth-child(6) { --i: 5; }
.results > .card:nth-child(n + 7) { --i: 6; } /* 6 * 40ms = the 240ms cap */
/* Where tree-counting functions exist, derive the index directly. */
@supports (order: sibling-index()) {
.results > .card { --i: calc(sibling-index() - 1); }
}
/* Reverse order on exit: last item leaves first. */
.results.is-leaving > .card {
animation: card-out 200ms ease-in both;
}
@supports (order: sibling-count()) {
.results.is-leaving > .card {
animation-delay: min(calc((sibling-count() - sibling-index()) * 30ms), 180ms);
}
}
@keyframes card-in { from { opacity: 0; translate: 0 12px; } }
@keyframes card-out { to { opacity: 0; translate: 0 -8px; } }
@media (prefers-reduced-motion: reduce) {
.results > .card,
.results.is-leaving > .card {
animation-name: fade; /* no travel */
animation-duration: 150ms;
animation-delay: 0s; /* nobody waits for a cascade */
}
@keyframes fade { from { opacity: 0; } }
}
Rendering Impact: composite. Every card animates only
opacityandtranslate; the stagger changes when each compositor animation starts, not what it touches. Spreading the starts also spreads the texture uploads for newly promoted cards across frames.
The nth-child(n + 7) rule shows why the cap makes the fallback cheap: past the capped position every item has the same delay, so only as many fallback rules as positions before the cap are needed, no matter how long the list grows.
@supports (order: sibling-index()) tests support by trying the function in a property that accepts integers. order is a convenient host; the test does not change the element’s order.
An eased stagger — delay proportional to the square root of the index, or another curve — often looks more natural than a linear one, because the first items react quickly and later ones settle. CSS has sqrt() and pow(), so calc(sqrt(sibling-index() - 1) * 90ms) expresses it directly.
Verification checklist
Constraints and trade-offs
sibling-index()counts all element siblings, including ones that are hidden or are not cards; wrap mixed content so the index reflects what the user sees.- Filtering a list with
display: nonedoes not renumber the remaining items for:nth-childfallbacks, but does for tree-counting functions in some cases; test the filtered state. - Grid staggers by row and column need both indices; a diagonal cascade can be approximated with
sibling-index()modulo the column count. - Very short steps — under about 20ms — are imperceptible as a cascade and only add work.
- Server-rendered
--ivalues survive in engines without the functions but must be regenerated when items are reordered on the client.
Frequently asked questions
Which browsers support sibling-index()?
It is a recent addition to CSS and is not yet available in every engine. Use it inside @supports with an index custom property as the fallback, so the stagger works everywhere and the markup can drop the inline index where the function exists.
Should staggered delays use animation-delay or transition-delay?
Either works; the index maths is the same. Use animation-delay for entrance animations that run when items are inserted, and transition-delay for state changes such as a menu opening, where the items already exist.
How long should a stagger step be?
Between about 30 and 60ms per item reads as a cascade without feeling slow. Pair it with a cap of a few hundred milliseconds so long lists do not keep the user waiting.
Does staggering reduce the work the browser does?
No, the total work is the same, but it is spread across more frames. That helps when many elements would otherwise be promoted and uploaded in one frame, which is the concern covered in budgeting concurrent animations.
Related
- Keyframe Architecture & State Mapping — the parent topic on driving keyframes from state
- Registering Custom Properties with @property — typed properties for index-driven values
- Budgeting Concurrent Animations per Frame — why spreading starts across frames matters