Everyone who has spent time creating staggered effects with CSS has reached for animation-delay or transition-delay at some point. Vary the delay slightly for each item, and you get that satisfying cascading effect. It works. But it’s not transferable. You can’t scrub through it, tie it to scroll, or link it to any external progress value. Each element lives in its own isolated timeline.
There’s a different way to think about staggered animation — not as a collection of independently timed objects, but as a single predictable, holistic effect driven by one progress value. You can then easily connect that progress value to an animation, scroll progress, or any other input to drive the staggering.
In this article, we will learn how this can be done using a mathematical formula, which I’ll call the stagger formula. We’ll build it step by step, understand exactly how it works, and implement it in CSS using the new @function rule. Plus, have some fun.
A Quick Note on Browser Support
Examples in this article use newer CSS features like @function rule and if(), sibling-index(), sibling-count() functions. These currently work best in the latest Chrome/Edge (as of May 2026), while support in Firefox and Safari is still limited or missing.
A Brief Intro to the Stagger Formula
Imagine as the animation progress of the staggered animation. It’s not the animation progress of an individual object. It’s the progress of the whole animation. And you can update ‘s value to drive the animation in a particular direction, somewhat like scrubbing the timeline in a video editor. You can link to @keyframes, transition, scroll-driven animations, or any other kind of motion. It opens up a lot of possibilities that are not possible with thinking in terms of individual objects separately.
The stagger formula makes it possible. You give it and a few more bits of data, it gives you back the animation progress of an individual object you are interested in. It looks like this:
In the following CodePen, you can play with different parameters to get a feel for them.
Notice the objects with muted colors on both sides and how the staggered animation can exist there. Also note that there is no slider for . It comes from the individual objects and is shown below them. The dashed-gray-bordered box highlights the elements that will animate as you move any slider.
It’s indeed a huge formula and can look intimidating, but it’s easier to understand than it may seem. We’ll build the formula later in this article. First, we need to understand how the animation actually works.
How Our Staggered Animation Works
The kind of staggered animation we are going to achieve is different from traditional ones because we don’t include time in our considerations. We figure out the relationship of the progress of the objects we are animating. It’s important to understand this relationship first to understand the formula.
First, I want you to know a few terms and conventions that I’ll use throughout this article.
- Animation order is an integer associated with an object that defines when it starts animation relative to other objects. Objects with a higher animation order start later, while objects with the same animation order start simultaneously. Each object under a staggered animation has an animation order. In most cases, the indices of the objects(retrieved via
sibling-index()) are exactly what we want as their animation orders. - Infinite ordered objects is an infinite sequence of objects with animation orders from low to high (left to right), corresponding to the integers on the number line.
Our real objects are only a very small part of it. Although it is not possible to have truly infinite objects in a computer, they provide a robust mathematical foundation for running staggered animations and reasoning about the process. The stagger effect will most likely originate in these imaginary objects, pass through our real objects (the middle part in our demo), and continue into the imaginary objects on the other side.

We use the convention of representing animation progress from start to finish within the range, both for individual objects and for the staggered animation as a whole.
Alright! Now we are ready to start defining the staggered animation. Suppose we have a sequence of infinite ordered objects where:
- At any time, exactly consecutive objects are animating (i.e., changing their animation progress).
- These objects have decreasing progress from left to right, with a constant gap , and all of their progress values increase at the same rate.
- All objects to the left of these objects have progress , and all objects to the right have progress .
Let the infinite ordered sequence of objects be:
Let us assume that the objects currently animating start at . Then at start time with have progress , and all with have progress .
When reaches progress , the progress of to objects look like:
| … | ||||
|---|---|---|---|---|
| … |
We assume all of these are in the valid animation progress range, that is, .
This configuration is the maximal valid configuration under the constraints (constant gap and equal rate), because increasing all progresses further would push beyond , which is not allowed.
Currently, we do not know the progress gap between and (which has progress ). However, if this gap is , we can shift our focus to the objects starting from and ending at , because they will all have a gap of between their progress values.
Since , this set of objects is already at its minimal valid configuration. Any decrease would push below , which is not allowed.
So we define to be equal to the gap between and , which is:
We can now easily find ‘s actual value:
Knowing the exact value of will later help us in building the stagger formula.
Thus, this new group can now evolve in the same way until reaches , and the process repeats. This creates a staggered animation that propagates smoothly across the sequence.
Building the Formula
Our foundation is now mathematically solid. We are ready to build the formula.
Let represent the animation order of the objects in infinite ordered objects sequence.
Let represent the animation progress of th object. This is what we want to find out for a given (the overall staggered animation progress).
We know that the difference between the animation progress of any two adjacent objects in the currently animating objects is . So let’s create a new sequence :
where . We can get that by multiplying by :
Which is:
We have also seen that the objects in the currently animating objects are in descending order. Our sequence is ascending. So let them descend in a new sequence by subtracting from some unknown number which we will figure out soon:
This is simple trick that you can use to turn an ascending sequence to a descending sequence. For example let’s you have:
If you subtract each of the above number from , you get:
Note that the numbers can be different from original set of numbers.
Now, if we replace with its value, we get
So looks like this for each of th object in our infinite ordered objects:
Note that the difference between two adjacent is still .
Can you see that somewhere among our infinitely ordered objects, there are progress values within the valid range , each separated by a gap of , in this sea of out of range animation progress values?
Let’s look at a concrete example with real values. Let and . Then a subset of the values of become:

We can see that the progress values within the range start at and end at .
We can also see that objects to the left of this range have , while objects to the right have . We can eliminate this issue using clamp() to flatten the values on each of these two sides to and respectively.
To figure out we need to know to exactly where to start and end our animation. Let’s introduce some more variables for pointing these positions:
- : The animation order of an object where the staggering animation starts.
- : The animation progress of th object at that time.
- : The animation order of an object where the staggering animation ends.
- : The animation progress of th object at that time.
Now, when , the following must hold:
Now we can easily figure out by replacing with its value:
Similarly when , because .
Now for range of the , the range of becomes . We need to do a linear mapping from one range of values to the other so that their ends match together.
We can easily find the value for for any value of in its range using the linear interpolation function :
We will later define using the CSS @function rule. What it does can be visualized like this: it uniformly stretches or squeezes the to scale of onto another scale whose lower bound is and upper bound is so that the two scales align. It then returns the corresponding value of on the other scale.

So for any in its range, becomes
But since we do not want the out of range animation progress values on either side, we will apply (the same clamp() function from CSS) to the values and obtain our final formula!
Finally, it’s Time to Animate with CSS
By now, you not only know the stagger formula, but also have a deep understanding of how it works. We can easily port it to CSS in a reusable way using the awesome new @function feature:
@function --stagger(
--m,
--k: 5,
--i: sibling-index(),
--u: 1,
--x: 0,
--v: sibling-count(),
--y: 1
) {
--q1: calc(var(--x) + var(--u) / var(--k));
--q2: calc(var(--y) + var(--v) / var(--k));
result: clamp(
0,
--lerp(var(--q1), var(--q2), var(--m)) - var(--i) / var(--k),
1
);
}Code language: CSS (css)
Note that we can use function calls for default values. These functions will be evaluated when --stagger() is called.
Implementing --lerp() is easy. For our purposes, we can define it like below:
@function --lerp(--a, --b, --x) {
result: calc(var(--a) + (var(--b) - var(--a)) * var(--x));
}Code language: CSS (css)
Above, we assume --x is in range.
Now we can animate a custom property --m to scrub our animation in an automated way:
@property --m {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@keyframes stagger-frames {
to {
--m: 1;
}
}
.parent {
animation: stagger-frames 2s linear alternate infinite;
}
.child {
--p: --stagger(
var(--m),
var(--k),
sibling-index(),
var(--u),
var(--x),
var(--v),
var(--y)
);
height: calc(var(--p) * 100%);
}Code language: CSS (css)
Note that we also set --k, --u, --x, --v, and --y without declaring them in CSS. We do this so we can define them on the parent <div> via slideVars and easily experiment with their values.
It’s working, but it feels a bit mechanical. To add more life to the animation, we can apply an easing function on top of --p. Unfortunately, we can’t use easing keywords or cubic-bezier() or linear() functions here. We are left on our own to create easing functions. I’m not sure if it’s possible to implement cubic-bezier() or linear() using @function. But we can recreate the ones from easings.net. For example, here is the CSS version of easeInOutQuad using @function and if():
@function --easeInOutQuad(--x <number>) returns <number> {
result: if(
style(--x: max(var(--x), 0.5)): calc(1 - (pow(-2 * var(--x) + 2, 2) / 2));
else: calc(2 * var(--x) * var(--x));
);
}Code language: JavaScript (javascript)
Note the type of --x is needed here for if() to work correctly. While it works fine without specifying the return type, I’m writing it out because we’re in the process of specifying types. Also note the clever use of max. It’s not yet possible to use comparison operators there. Here is a comparison with and without this easing function:
.child {
--p: --stagger(
var(--m),
var(--k),
sibling-index(),
var(--u),
var(--x),
var(--v),
var(--y)
);
}
.parent.one .child {
height: calc(var(--p) * 100%);
}
.parent.two .child {
height: calc(--easeInOutQuad(var(--p)) * 100%);
}Code language: CSS (css)
You can even take it further by applying a non-linear animation-timing-function to the entire animation. For example, using steps() lets you create a stop-motion effect:
.parent.three {
animation: stagger-frames 2s steps(15, jump-none) alternate infinite;
}
.child {
--p: --stagger(
var(--m),
var(--k),
sibling-index(),
var(--u),
var(--x),
var(--v),
var(--y)
);
}
.parent.one .child {
height: calc(var(--p) * 100%);
}
.parent.two .child,
.parent.three .child {
height: calc(--easeInOutQuad(var(--p)) * 100%);
}Code language: CSS (css)
With the following function, we can tweak the output of --stagger() or even --easeInOutQuad() to create a smooth bump (I found it with the help of AI. AI can be really helpful in figuring out such functions):
@function --smoothBump(--x <number>) returns <number> {
result: calc((1 - cos(2 * pi * var(--x))) / 2);
}
.parent.four .child {
height: calc(--smoothBump(--easeInOutQuad(var(--p))) * 100%);
}Code language: CSS (css)
For some more fun, let’s connect our animation to scroll. It’s now easier than ever. With just two lines of code, you can link the animation directly to the user’s scroll position.
.parent {
animation: stagger-frames;
animation-timeline: scroll();
}
.parent.three {
animation: stagger-frames steps(15, jump-none);
animation-timeline: scroll();
}Code language: CSS (css)
Let’s finish with a more interesting example. If you scroll all the way down this page, you’ll see a cool animation of a number appearing that represents the donation amount from Frontend Masters to open source projects.
This is one of those ideal scenarios where the stagger formula shines. It allows you to make effects like below:
Conclusion
That’s it for this article. By taking time out of the equation and thinking in terms of progress-value relationships, we made scrubbable staggered animation possible. I found this core idea in a Blender tutorial and fell in love with the technique. Thanks to Filip, the creator of the tutorial. Finally thanks to CSS @function and modern CSS features for making it happen painlessly on the web without any JavaScript.
I’d love to see what you’ll build with it. If you make something, share it in the comments below.
