9 CSS Stagger Animation Demos You Can Copy-Paste
A css stagger animation gives each item in a list or grid its own delay, so a group that starts together finishes appearing one after another instead of all at
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 List cascade
- 02 Grid wave stagger
- 03 Letter-by-letter stagger
- 04 Sequential 3D card flip
- 05 Reverse-order exit
- 06 Circular ripple stagger
- 07 Delay via --i and one-line calc()
- 08 Auto delay via sibling-index()
- 09 Pseudo-random delay via SCSS random()
The order below tracks how each delay gets computed, not how flashy the result looks. 01 through 03 lean on plain nth-child math against a fixed item count. 04 through 06 build on that same idea with 3D rotation, a reversed exit direction, and distance from a center point. 07 and 08 skip counting items by hand altogether, one through an inline custom property and the other through a native browser function that reads sibling position directly. 09 takes a third approach — a delay picked once at compile time — but still loops over a fixed count the same way 01 through 06 do. Each code block below keeps only the rule that sets the delay; the full component, markup included, ships in the zip.
01List cascade
Three pill-shaped rows sit slightly shrunk and dropped 14px below their resting spot, then scale and slide into place 90ms apart as an SCSS loop assigns the delay by nth-child. The bouncy easing reads livelier than a flat fade, which suits a notification feed or a search-results list where rows keep arriving.
.cascade li {
opacity: 0;
transform: translateY(14px) scale(.9);
animation: cascade-loop $duration $easing infinite;
}
@for $i from 1 through 3 {
li:nth-child(#{$i}) { animation-delay: ($i - 1) * $stagger; }
}
@keyframes cascade-loop {
0%, 8% { opacity: 0; transform: translateY(14px) scale(.9); }
26%, 66% { opacity: 1; transform: translateY(0) scale(1); }
86%, 100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
02Grid wave stagger
Nine cells sit in a 3x3 grid, and each one's delay comes from adding its own row index to its column index, then multiplying that sum by 70ms. The bottom-right cell waits the longest of all nine. The diagonal spread reads as a wave moving out from the top-left corner, which fits a dashboard card grid or a thumbnail gallery loading in.
$cols: 3;
@for $i from 1 through 9 {
$row: math.floor(math.div($i - 1, $cols));
$col: ($i - 1) % $cols;
.cell:nth-child(#{$i}) { animation-delay: ($row + $col) * $wave; }
}
@keyframes wave-loop {
0%, 6% { opacity: 0; transform: scale(.5); }
22%, 62% { opacity: 1; transform: scale(1); }
84%, 100% { opacity: 0; transform: scale(.7); }
}
03Letter-by-letter stagger
Each character of a heading sits in its own span and pops up from 16px below, 30ms after the letter before it. A five-character word arrives as a quick cascade instead of one block of text fading in together. It fits a hero headline or a logo intro, where the first thing on the page deserves its own small entrance.
.letters span {
opacity: 0;
transform: translateY(16px);
animation: letter-loop $duration $easing infinite;
}
@for $i from 1 through 5 {
span:nth-child(#{$i}) { animation-delay: ($i - 1) * $stagger; }
}
@keyframes letter-loop {
0%, 4% { opacity: 0; transform: translateY(16px); }
20%, 66% { opacity: 1; transform: translateY(0); }
86%, 100% { opacity: 0; transform: translateY(-10px); }
}
04Sequential 3D card flip
Three plan cards start turned 90 degrees on the Y axis inside a parent carrying perspective, then unfold to face forward one at a time, 150ms apart. Because the turn happens in true 3D space instead of as a flat scale, each card reads as an object rotating toward the viewer, which suits a pricing comparison or a stack of spec cards.
.cards { perspective: 600px; }
.card {
opacity: 0;
transform: rotateY(90deg);
transform-style: preserve-3d;
animation: flip-loop $duration $easing infinite;
}
@for $i from 1 through 3 {
.card:nth-child(#{$i}) { animation-delay: ($i - 1) * $stagger; }
}
@keyframes flip-loop {
0%, 6% { opacity: 0; transform: rotateY(90deg); }
26%, 66% { opacity: 1; transform: rotateY(0deg); }
86%, 100% { opacity: 0; transform: rotateY(-90deg); }
}
05Reverse-order exit
Three saved-state pills enter top to bottom in list order — the first arriving first, the third arriving last — each on its own three-stage keyframe instead of a shared delay value. Leaving runs backward: the third pill, the one that arrived last, fades out first, and the first pill holds on screen until the very end. That reversal suits a stack of toast notifications or a list of pending deletions, where whatever showed up most recently should also be the one to disappear first.
@keyframes exit-1 {
0%, 5% { opacity: 0; transform: translateY(-8px); }
15%, 78% { opacity: 1; transform: translateY(0); }
90%, 100% { opacity: 0; transform: translateY(-8px); }
}
@keyframes exit-3 {
0%, 25% { opacity: 0; transform: translateY(-8px); }
35%, 53% { opacity: 1; transform: translateY(0); }
65%, 100% { opacity: 0; transform: translateY(-8px); }
}
.toasts li:nth-child(1) { animation: exit-1 $duration $easing infinite; }
.toasts li:nth-child(3) { animation: exit-3 $duration $easing infinite; }
06Circular ripple stagger
Nine dots sit in a 3x3 grid, and each one's delay is set by its straight-line distance from the center dot. The center itself waits zero milliseconds, the four edge dots wait one step, and the four corner dots wait 1.41 steps for the diagonal. The result reads as a ring expanding outward from the middle, which suits a like-button burst or a grid of notification icons all reacting to one tap.
.ripple span {
opacity: 0;
transform: scale(.3);
animation: ripple-loop $duration $easing infinite;
}
// distance from the center cell (5th of 9) times $step — corners 1.41, edges 1, center 0
span:nth-child(1) { animation-delay: $step * 1.41; }
span:nth-child(5) { animation-delay: 0ms; }
span:nth-child(9) { animation-delay: $step * 1.41; }
@keyframes ripple-loop {
0%, 4% { opacity: 0; transform: scale(.3); }
24%, 58% { opacity: 1; transform: scale(1); }
84%, 100% { opacity: 0; transform: scale(.6); }
}
07Delay via --i and one-line calc()
Each of the four rows carries only an inline --i value in its markup — 0, 1, 2, 3 — and one declaration, animation-delay: calc(var(--i) * 80ms), turns that number into a staggered entrance with no SCSS loop at all. Because the count lives in the markup instead of a compiled rule per item, adding a fifth row just means writing style="--i:4" and nothing in the stylesheet has to change. It suits a general-purpose list component that different pages reuse with different row counts.
// html: <li style="--i:0">, <li style="--i:1">, ...
.i-list li {
opacity: 0;
transform: translateY(10px);
animation: i-loop $duration $easing infinite;
animation-delay: calc(var(--i) * 80ms);
}
@keyframes i-loop {
0%, 6% { opacity: 0; transform: translateY(10px); }
22%, 68% { opacity: 1; transform: translateY(0); }
88%, 100% { opacity: 0; transform: translateY(6px); }
}
08Auto delay via sibling-index()
Six tiles get no inline --i and no compiled nth-child rule at all — animation-delay: calc(sibling-index() * 100ms) reads each element's own position among its siblings directly inside the browser. That makes the markup lighter than item 07's, since nothing needs to be written into the HTML, but it currently only runs in Chrome and Edge 138 and newer (MDN's sibling-index() reference).
.tiles span {
opacity: 0;
transform: scale(.5);
animation: tile-loop $duration $easing infinite;
animation-delay: calc(sibling-index() * 100ms);
}
@keyframes tile-loop {
0%, 6% { opacity: 0; transform: scale(.5); }
22%, 68% { opacity: 1; transform: scale(1); }
88%, 100% { opacity: 0; transform: scale(.7); }
}
09Pseudo-random delay via SCSS random()
Five badges each get a delay somewhere between 0 and 420ms from SCSS's random() function, called once per badge inside a compile-time loop rather than once per page load. Because sass resolves random() while turning style.scss into style.css, those five values are picked exactly once and then sit fixed in the shipped stylesheet — reloading the page never reshuffles which badge lands first.
@for $i from 1 through 5 {
.badge:nth-child(#{$i}) { animation-delay: random($max-delay) * 1ms; }
}
@keyframes badge-loop {
0%, 4% { opacity: 0; transform: translateY(10px) scale(.9); }
16%, 60% { opacity: 1; transform: translateY(0) scale(1); }
84%, 100% { opacity: 0; transform: translateY(-6px) scale(.95); }
}
Where it breaks — the traps
Six of the nine — 01 through 06 and 09 — bake a fixed item count into the stylesheet itself, whether that's an SCSS loop running to 3 or nine hand-written nth-child rules for a 3x3 grid. MDN's :nth-child() documentation is clear that the selector matches against the real DOM position at render time, not against however many rows someone had in mind while writing the SCSS. So if a sixth toast or a tenth grid cell shows up later at runtime, the stylesheet needs recompiling with a longer loop. Otherwise, that new element gets no delay rule and just pops in with everything else at once. Items 07 and 08 exist to dodge precisely this trap. 07 moves the count into markup a template can regenerate on the fly, and 08 sidesteps counting altogether by asking the browser for each element's own sibling position.
Item 09 hides a quieter version of the same mistake. random(420) inside a compile-time loop looks like it should hand out a fresh spread of delays on every visit, but sass only runs that function once, while building style.css from style.scss. Anyone who unzips this set and opens that compiled file will find 8dxrtqpt unlocks it, typed as seven plain lowercase letters and one digit with nothing extra around them. Inside, the five badge delays are already the exact numbers this demo is showing right now, not a new roll of the dice.
Accessibility
All nine jump straight to their finished, fully visible state under prefers-reduced-motion — no animation, no delay, every list item, cell, letter, card, dot, tile, or badge sitting in place from first paint onward. Items 01 through 04 and 06 through 09 each cancel a single keyframes loop to get there. Item 05 has more moving parts to shut off: because its entrance and its exit run on three separate, direction-specific keyframes rules rather than one shared transition, reduced motion has to cancel three different animations at once and settle every pill into its always-visible middle state. MDN's guide to prefers-reduced-motion covers which operating-system settings flip this media query on.
@media (prefers-reduced-motion: reduce) {
li, span, .card, .badge { animation: none !important; opacity: 1; transform: none; }
}
Nine more entrance patterns sit in the CSS demo archive, and what this site actually sells versus what it just shows for free is explained on the about page.
FAQ
Does a css stagger animation need any JavaScript?
No. All nine here run on animation-delay values set entirely in CSS, whether that value comes from a compiled nth-child rule, an inline --i custom property, or the native sibling-index() function. The zip's react/ folder wraps the same SCSS inside components without adding any JS that drives the motion itself.
Should I reach for --i or sibling-index() in a real project?
Pick item 07's --i and calc() if the page has to work in every browser people actually use today — it only needs an inline style attribute a template can already generate for each row. Save item 08's sibling-index() for an internal tool or admin panel that only has to run on Chrome or Edge 138 and newer, where it skips writing that inline value at all.
Does item 09's random delay change on every page reload?
No. SCSS's random() function runs once, while sass compiles style.scss into style.css, so the five delays it picks are locked into that file long before a browser ever loads it. Getting a different spread on every visit needs JavaScript to set a custom property like --delay at load time — CSS by itself always replays the same fixed set of numbers.