GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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 of the css animation code examples below keeps only the rule that sets the delay; the full component, markup included, ships in the zip. When the items should never stop arriving at all, an endless loop replaces the delay math — that's the CSS marquee infinite loop — and when the point is progress rather than order, the CSS scroll progress bars cover it.

01List cascade

Where the css animation delay comes from

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.

Where it fits

The bouncy easing reads livelier than a flat fade, which suits a notification feed or a search-results list where rows keep arriving.

List cascade
Open in a new tab

01 · List cascade — Measured area 9.301% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

scale+translateY90ms 간격ease-pop
.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

How it works

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.

Where it fits

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.

Grid wave stagger
Open in a new tab

02 · Grid wave stagger — Measured area 11.562% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

행+열 인덱스70ms 계수대각선 확산
$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

How it works

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.

Where it fits

It fits a hero headline or a logo intro, where the first thing on the page deserves its own small entrance.

Letter-by-letter stagger
Open in a new tab

03 · Letter-by-letter stagger — Measured area 4.64% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

글자별 span30ms 간격translateY 바운스
.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

How it works

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.

Where it fits

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.

Sequential 3D card flip
Open in a new tab

04 · Sequential 3D card flip — Measured area 13.364% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateY 90→0150ms 간격perspective
.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

How it works

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.

Where it fits

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.

Reverse-order exit
Open in a new tab

05 · Reverse-order exit — Measured area 6.794% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

역순 delay50ms 간격enter/exit 분리
@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

How it works

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.

Where it fits

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.

Circular ripple stagger
Open in a new tab

06 · Circular ripple stagger — Measured area 9.975% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

중심 거리 delay동심원 확산scale+opacity
.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()

How it works

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.

Where it fits

It suits a general-purpose list component that different pages reuse with different row counts.

Delay via --i and one-line calc()
Open in a new tab

07 · Delay via --i and one-line calc() — Measured area 13.745% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

--i 변수calc() 한 줄인라인 style
// 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()

How it works

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.

Where it fits

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).

Auto delay via sibling-index()
Open in a new tab

08 · Auto delay via sibling-index() — Measured area 5.736% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

sibling-index()반복문 없음Chrome/Edge 138+
.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()

How it works

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.

Where it fits

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.

Pseudo-random delay via SCSS random()
Open in a new tab

09 · Pseudo-random delay via SCSS random() — Measured area 6.827% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

SCSS random()비선형 지연컴파일타임 고정
@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.

The two that dodge it

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.

random() runs once

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. Layering light onto that one-after-another rhythm is where the CSS glow and neon effects meet the same delay values.

Accessibility

Reduced motion

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.

The one with three animations

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, or is a css animation delay enough?

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. Running that same delay math down a vertical sequence of steps is what the CSS timeline animations collect.

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.

Enter the archive password

The password is inside this article. You will find it as you read.