Stagger animations look simple on the surface, but somehow they always end up dragging in more code than they should.
Most of the time, you reach for JavaScript, loop through elements, calculate delays, and inject them inline. It works, but it’s not really what JavaScript should be doing. And if you’ve ever had animations break because the DOM wasn’t ready or someone added a new card without updating delays, you know how annoying that gets.
CSS doesn’t feel much cleaner either. Dropping --i: 1, --i: 2, --i: 3 into your markup just to control animation-delay hardcodes order into HTML, which isn’t great.
That’s exactly what sibling-index() fixes. It’s a native CSS function that lets each element know its position among siblings, no JavaScript, no custom props in your markup. You define the animation once, and the browser handles the sequencing.
This article breaks down how it works, why it exists, and what stagger animations look like when you strip JavaScript out of the equation.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
sibling-index()?Before we dive into animation, let’s clarify what the sibling-index() function does.
sibling-index() is a CSS function that gives you an element’s position within its parent, starting at 1. In a list of five items, the first <li> is 1, the second is 2, and so on. There’s nothing to configure, no arguments to pass. It simply returns a number based on where the element sits in the DOM.
Its real advantage is that you can use it in a calc() expression. Instead of writing this:
li:nth-child(1) { animation-delay: 0ms; }
li:nth-child(2) { animation-delay: 100ms; }
li:nth-child(3) { animation-delay: 200ms; }
You can simplify it to this:
li {
animation-delay: calc(sibling-index() * 100ms);
}
This achieves the same result with just one rule. It works automatically with any number of elements added.
<sibling-index()> = sibling-index()
The sibling-index() function takes no arguments and returns an integer.
This function is part of the CSS Functions and Values Level 5 specification, along with sibling-count(), which gives you the total number of siblings. Both functions are still experimental, so check caniuse before using them in your project. Currently, Chrome, Edge, and Safari have the most support, but other browsers might not fully support them yet. We will discuss fallbacks later.
If you have experience building user interfaces, you have likely written some code like this:
const items = document.querySelectorAll('.card');
items.forEach((item, index) => {
item.style.animationDelay = `${index * 100}ms`;
});
This code is not awful. However, it reveals a bigger issue. You are using JavaScript to set a visual property because CSS cannot directly determine an element’s position relative to its siblings. JavaScript acts as a middleman for a layout concern that should be handled by CSS.
The situation gets trickier with dynamic lists. If you load items from an API, add cards based on user actions, or have a framework inject rows, you need to run that loop each time there is an update. You might need to use a MutationObserver, or worse, forget to run it and wonder why new items do not animate.
The CSS solution replaces one problem with another. It involves setting a custom property directly in your HTML like this:
<ul> <li style="--i: 1">Item one</li> <li style="--i: 2">Item two</li> <li style="--i: 3">Item three</li> </ul>
Then in your CSS, you would use it like this:
li {
animation-delay: calc(var(--i) * 100ms);
}
This approach keeps JavaScript out of it, which is an improvement. But now you are storing order information in your HTML, which is not ideal. Your markup should represent structure, not sequence. If you reorder elements or generate them on the server, you have to update those indices, which can lead to bugs that are hard to fix.
Both methods aim to solve the same issue: CSS lacks a simple way to count sibling elements. The sibling-index() function can effectively solve this problem.
sibling-index in actionLet’s start with something visual before we build anything complex. Here’s a list that folds in with a 3D perspective flip, one item after the other. Pay attention to how each item hinges open and snaps into place before the next one starts:
See the Pen
3D Transform-origin with sibling-index() by Miracle Jude (@JudeIV)
on CodePen.
Uncomment the REPLAY SEQUENCE button and the JS code to try it out.
The rotateX(-90deg) makes each element start off flat, like a page opening. The translateZ(-50px) moves it back in 3D space, making it look like it’s coming toward you as it unfolds. The filter: blur(10px) gives a soft effect at first, which sharpens as the element lands.
The --reveal-timing cubic bezier also plays an important role. The spring-like curve cubic-bezier(0.34, 1.56, 0.64, 1) causes each flip to slightly overshoot at the end. This adds a sense of weight and character instead of a mechanical feel from the ease-out.
The stagger effect is still done in one line:
animation-delay: calc(sibling-index() * 120ms);
The multiplier is higher than in the basic fade because the flip animation is more dramatic. You need enough space between the items so that the eye can follow each one landing before the next starts.
This is where the functions sibling-index() and sibling-count() start to show how useful they are, especially beyond just animation delays. The goal is to arrange a group of elements in a perfect circle around a central element. Typically, you would need to calculate each element’s position using JavaScript, apply trigonometry, and set styles directly. However, you can achieve this using only CSS. Here’s what that looks like: add a new node using the button and watch the circle redistribute itself automatically:
See the Pen
Radial Orbit sibling-index() by Miracle Jude (@JudeIV)
on CodePen.
The key line is this:
--angle: calc((sibling-index() / sibling-count()) * 360deg);
To break it down simply: the function sibling-index() divided by sibling-count() gives you a fraction. This tells you where the element is in relation to the whole group. If you multiply that fraction by 360deg, you get the angle for each element around a circle. The first of six elements gets 60deg, the second gets 120deg, and so on.
The transformation does two things. The command rotate(var(--angle)) translateY(-120px) moves the element outward from the center along that angle, like pushing it along a spoke of a wheel. Then, rotate(calc(var(--angle) * -1)) turns it back to keep the element upright, so it doesn’t tilt with the spoke.
The geometry is self-healing. Add a seventh node and the browser recalculates sibling-count() for every element automatically; the circle redistributes without touching a single line of JavaScript or CSS.
The stagger animation on top uses animation-delay: calc(sibling-index() * 100ms) to make each element appear in order. This makes the circle look like it is coming together instead of just showing up all at once.
One more pattern to show combines several CSS properties for an effect that would be complex in JavaScript but looks clean in CSS. The idea is a card grid where each card doesn’t just fade in. It slides up, straightens, scales to full size, and shifts from grayscale to color, all at the same time, with each card starting at different times based on its index. Here’s the full effect notice how each card doesn’t just appear, it shifts from dark and desaturated to full color as it lands:
See the Pen
wave gallery sibling-index() by Miracle Jude (@JudeIV)
on CodePen.
The skewY(5deg) at the beginning is small but significant. It tilts the card slightly as it comes in, making the motion feel more interesting than just moving in a straight line. The filter chain applies both the desaturation and brightness lift at the same time, so cards change from dark and colorless to full color as they arrive.
For four cards, a 60ms timing works well. If you increase the grid to eight or twelve cards, reduce this timing to 40ms. If the last card takes too long to animate, users may start interacting with the early cards before everything is settled.
On its own, sibling-index() solves the stagger problem. But it gets more interesting when you pair it with some of the other modern CSS features that have landed recently, because the combinations unlock things that used to need JavaScript entirely.
@starting-style for entry animationsMany developers use JavaScript to animate elements hidden with display: none, like filter panels, tab switches, or modals. CSS transitions don’t fire on display changes, so the common idea was that you need JavaScript for this.
@starting-style changes that. It allows you to set an element’s styles before it shows for the first time. This gives the browser a starting point to animate from. When you combine it with sibling-index(), you can create staggered animations for content that appears dynamically, not just when the page loads:
.card {
transition: opacity 0.4s ease, translate 0.4s ease;
transition-delay: calc(sibling-index() * 60ms);
@starting-style {
opacity: 0;
translate: 0 16px;
}
}
When the cards change from being hidden to visible, like after you switch a filter or a tab, the browser picks up those @starting-style values and transitions from there. This staggered effect happens automatically. You don’t need any JavaScript for the reveal trigger.
Scroll-driven animations let you tie an animation’s progress to scroll position rather than time. On their own, they’re already useful. With sibling-index() layered on top, siblings that enter the viewport together still stagger instead of all snapping in at once:
@keyframes slideIn {
from {
opacity: 0;
translate: 0 24px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.card {
animation: slideIn 1s ease forwards;
animation-timeline: view();
animation-range: entry 0% entry 40%;
animation-delay: calc(sibling-index() * 40ms);
}
The property animation-timeline: view() connects the animation to whether the element is visible in the viewport. The animation-range property sets when the animation starts and ends as the element enters the view. Without the sibling-index() function, all cards in a row would animate in the same way. With it, they animate one after another.
scroll-driven animations need a backup for Firefox. Support has improved, but it’s not perfect yet. Make sure to check before you launch.
sibling-count() as a companionThe function sibling-count() gives you the total number of siblings, not just the current position. On its own, that’s somewhat useful, but when you combine it with sibling-index(), you can do proportional calculations. This helps in distributing colors evenly across a color wheel, no matter how many items there are:
.card {
background-color: hsl(
calc(sibling-index() / sibling-count() * 360deg)
60%
85%
);
animation-delay: calc(sibling-index() * 60ms);
}
The fraction sibling-index() / sibling-count() gives each element its share of the whole. Multiply by 360deg and you get colors distributed evenly around the full color wheel, three cards or thirty, the spread is always proportional. The same logic applies to sizing, spacing, or any value that should scale with the number of siblings rather than a hardcoded list.
This feature is useful, but there are some important limitations to know before using it in production code.
This is the main concern. As of now, sibling-index() is still experimental. Chrome, Edge, and Safari have made progress; it is available behind a flag in recent versions, but Firefox does not support it yet. Therefore, you should not rely on this as your main animation method for a production app that needs to work across different browsers without a backup plan.
The best approach is to use feature detection with @supports:
/* fallback for browsers that don't support sibling-index() */
.flip-list li {
opacity: 0;
animation: perspectiveFlip 0.8s ease forwards;
}
.flip-list li:nth-child(1) { animation-delay: 0ms; }
.flip-list li:nth-child(2) { animation-delay: 120ms; }
.flip-list li:nth-child(3) { animation-delay: 240ms; }
.flip-list li:nth-child(4) { animation-delay: 360ms; }
/* override with the clean version where it's supported */
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.flip-list li {
animation-delay: calc(sibling-index() * 120ms);
}
}
This method may not be perfect, but it works. The nth-child method helps older browsers, while sibling-index() will take over where it’s supported. If your list length is unpredictable, consider using JavaScript for browsers that don’t support it, while using sibling-index() as an enhancement.
sibling-index() counts only direct siblings. It does not look at parents, nested elements, or deeper structures.
This can confuse developers when they have more complex layouts:
<section class="card-grid">
<div class="card-wrapper">
<article class="card">...</article>
</div>
<div class="card-wrapper">
<article class="card">...</article>
</div>
</section>
In this case, if you apply sibling-index() to .card, every card will get a value of 1 because each .card is the only child of its wrapper. To fix this, apply it to .card-wrapper instead. Try to simplify your markup. If your setup has extra divs for styling, place sibling-index() at the wrapper level.
sibling-index() runs when the page renders, which is helpful if you add a new item to the DOM; the browser updates automatically. However, any animations that have already played will not replay just because the index changed. If you insert items into the middle of a list and expect all animations to restart, you will need to find a way to trigger the animation again.
For mostly static lists, like menus or pricing cards, this is not a problem. But for dynamic lists where items are often added, removed, or rearranged, you’ll still need JavaScript to manage the animations. sibling-index()is useful for simple cases, but not a substitute for a robust animation library.
Switching from JavaScript to CSS does not automatically make your animations faster. The same performance rules apply: use opacity and transform properties (like translate, scale, or rotate), but avoid animating properties that affect layout, such as width, height, top, or margin. The browser cannot handle these on the GPU, which may cause lag, regardless of the involvement of JavaScript.
There’s a pattern that keeps repeating in CSS, something that used to require JavaScript, a library, or a clever hack, eventually gets a native solution that makes the old approach look unnecessarily complicated. sibling-index() is the latest example of that.
Stagger animations were never hard to understand. The concept is simple: delay each element a little more than the one before it. What made them annoying was the gap between what you wanted to express and what CSS could actually do. That gap is closing.
To recap: sibling-index() returns an element’s position among its siblings, starting from one. Feed that number into calc() to drive animation-delay and the browser handles the rest. Pair it with @starting-style for dynamically revealed content, scroll-driven animations for viewport-triggered sequences, and sibling-count() when you need proportional values across a set. Watch the direct-sibling constraint, have a fallback ready for unsupported browsers, and don’t animate layout properties regardless of how the delay is calculated.
Browser support means you can’t go all-in today for most production projects. But that’s not a reason to ignore it; it’s a reason to start using it as progressive enhancement now, so you’re ready when support lands fully. The fallback is a handful of nth-child rules. The upside is cleaner code that scales without touching JavaScript.
CSS keeps getting better at the things we used to outsource. sibling-index() is a small function with a specific job, but it represents something bigger: the platform catching up to patterns developers have been hacking around for years.
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
LogRocket lets you replay user sessions, eliminating guesswork around why bugs happen by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
Modernize how you debug web and mobile apps — start monitoring for free.

useEffect breaks AI streaming responses in ReactSee why useEffect breaks AI streaming in React, and how moving stream state outside React fixes flicker and stale updates.

A real-world debugging session using Claude to solve a tricky Next.js UI bug, exploring how AI helps, where it struggles, and what actually fixed the issue.

CSS wasn’t built for dynamic UIs. Pretext flips the model by measuring text before rendering, enabling accurate layouts, faster performance, and better control in React apps.

Why do real-time frontends break at scale? Learn how event-driven patterns reduce drift, race conditions, and inconsistent UI state.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now