Stagger Animation in CSS: animation-delay, nth-child and JS Approaches
Master CSS stagger animations using animation-delay, nth-child formulas, and JavaScript. Build lists, cards, and menus that cascade beautifully without heavy libraries.
What Stagger Animation Actually Is
Stagger animation is the technique of playing the same animation on a group of elements — cards, list items, nav links, grid cells — but offsetting each element's start time by a fixed increment. The result is that cascade effect where items appear to flow in one after another instead of all popping on-screen at once. You've seen it everywhere: onboarding checklists that tick in one by one, hero grids where each card fades up with a 60ms gap, navigation menus where links slide in left-to-right.
It sounds deceptively simple. It is, mostly — but there are three meaningfully different ways to do it in 2026, and each one has real tradeoffs. You can declare the delays statically in CSS using animation-delay, calculate them dynamically with nth-child, or drive the whole thing from JavaScript by writing to custom properties or setting inline styles. Picking the wrong approach for your context creates either maintenance hell or unnecessary JS weight.
In practice, the pure-CSS approaches are underused. Most devs reach for Framer Motion or GSAP the moment they see a stagger requirement, without realising that for 80% of use cases, 10 lines of CSS and a :nth-child formula is everything you need. That said, JS does unlock things CSS can't — like staggering based on scroll position or reversing the sequence on hover-out.
The Hardcoded animation-delay Approach
The most obvious approach: write a keyframe, apply it to every item in your list, then manually set a different animation-delay on each one. It works for tiny, fixed-size lists where you know the element count at build time.
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-item {
animation: fadeUp 0.4s ease-out both;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 80ms; }
.list-item:nth-child(3) { animation-delay: 160ms; }
.list-item:nth-child(4) { animation-delay: 240ms; }
.list-item:nth-child(5) { animation-delay: 320ms; }The both fill-mode is the detail people forget. Without it, the element flashes visible at its final state before the delay resolves, then jumps to the from state when the animation starts. both applies forwards *and* backwards fill, so the element stays at opacity: 0 during the delay period. Missing this produces a visible flicker that's hard to debug.
Honestly, this approach falls apart fast. If you add a sixth item and forget to add .list-item:nth-child(6), it animates with no delay and blows up the cascade visually. It's also verbose to maintain and completely ignores dynamic content. Keep it for fixed navigation menus or footer columns where the count never changes — 3 to 5 items max.
Worth noting: the 80ms increment used here is intentional. Human perception research from the early 2010s found that stagger intervals below 50ms register as simultaneous, while anything above 200ms feels slow and boring. The sweet spot is 60px–100ms per step for most UI contexts.
CSS Custom Properties + nth-child Formula
This is the approach you should be using for most stagger problems. Instead of hardcoding a delay for every single element, you assign each element a --i custom property that represents its index, then compute the delay from that property in a single rule.
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.list-item {
/* fallback so --i is always defined */
--i: 0;
animation: fadeUp 0.45s ease-out both;
animation-delay: calc(var(--i) * 70ms);
}
/* nth-child sets the counter */
.list-item:nth-child(1) { --i: 0; }
.list-item:nth-child(2) { --i: 1; }
.list-item:nth-child(3) { --i: 2; }
.list-item:nth-child(4) { --i: 3; }
.list-item:nth-child(5) { --i: 4; }
.list-item:nth-child(6) { --i: 5; }
.list-item:nth-child(7) { --i: 6; }
.list-item:nth-child(8) { --i: 7; }You still have to list each :nth-child rule, but now changing the step interval is one number (70ms) rather than recalculating every delay value. You can also cap the delay for long lists: animation-delay: min(calc(var(--i) * 70ms), 560ms) — this prevents the last item in a 20-item list from waiting 1.4 seconds to appear.
One more thing — if you're in Sass or PostCSS you can generate the :nth-child rules with a loop and never touch them again:
$stagger-count: 12;
@for $i from 1 through $stagger-count {
.list-item:nth-child(#{$i}) {
--i: #{$i - 1};
}
}This pairs naturally with Empire UI's component system, where most animated lists use exactly this pattern internally. The style hubs — like claymorphism and neobrutalism — each ship their card grids with pre-configured stagger variables you can override at the consuming level.
JavaScript-Driven Stagger: Inline Styles and Custom Properties
When your list is dynamic — server-rendered content, infinite scroll, a React component that doesn't know its children count at compile time — you need JS to set the delays. The cleanest approach is looping over the elements and writing --i directly as an inline style. This way your CSS animation rule stays unchanged; only the index value varies per element.
// stagger.ts
export function applyStagger(
container: HTMLElement,
selector = '[data-stagger]',
) {
const items = container.querySelectorAll<HTMLElement>(selector);
items.forEach((el, i) => {
el.style.setProperty('--i', String(i));
});
}In React, you'd wire this up with a useEffect after mount, or — even cleaner — just pass the index through from the parent during render. If you're mapping over an array anyway, you already have the index:
function AnimatedList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, i) => (
<li
key={item}
className="list-item"
style={{ '--i': i } as React.CSSProperties}
>
{item}
</li>
))}
</ul>
);
}Quick aside: TypeScript will complain that --i isn't a valid CSSProperties key. The cast to React.CSSProperties shuts that up, but it's deliberately unsafe — you're telling the type system to trust you. A stricter pattern is to declare the property in a d.ts file, but for component-local stagger indexes the cast is fine in practice.
The JS approach also unlocks scroll-triggered stagger: observe when the container enters the viewport with IntersectionObserver, then add a class that starts the animations — and you can dynamically set the delays at that moment based on how many items are visible. This is something pure CSS animation-delay simply can't do. Look, if you're building scroll animations that need per-element timing, JS is the right tool.
Stagger with the Web Animations API
If you want full programmatic control without pulling in a library, the Web Animations API (WAAPI) has been production-stable since Chrome 84 and Firefox 75 — we're talking 2020-era baseline compatibility. It lets you animate elements imperatively, set individual timing configs per element, and get back Animation objects you can pause, reverse, or seek.
function staggerFadeUp(container: HTMLElement, step = 70) {
const items = container.querySelectorAll<HTMLElement>('.list-item');
items.forEach((el, i) => {
el.animate(
[
{ opacity: 0, transform: 'translateY(16px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
duration: 450,
delay: i * step,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
},
);
});
}The cubic-bezier(0.22, 1, 0.36, 1) easing — sometimes called "ease-out expo" — is worth memorising. It starts fast and decelerates hard at the end, which makes cascade animations feel snappy and physical rather than floaty. You won't find it in the standard CSS easing keywords.
Where WAAPI really shines is reverse-on-exit stagger. When the user closes a dropdown or navigates away, you almost always want the items to animate *out* in reverse order — last-in, first-out. With WAAPI you can reverse the Animation objects you stored, or re-run the loop with a delay formula that counts backward: delay: (items.length - 1 - i) * step.
That said, if your project already depends on Framer Motion, using WAAPI directly is probably not worth the added complexity. Framer Motion's staggerChildren inside AnimatePresence handles both enter and exit stagger with three lines of config. Use WAAPI when you need the capability without the library weight — vanilla projects, Web Components, or performance-sensitive micro-animations.
Accessibility: prefers-reduced-motion and Sensible Defaults
Stagger animation is one of the most motion-heavy patterns in web UI. Some users — people with vestibular disorders, migraines, or motion sensitivity — have set prefers-reduced-motion: reduce in their OS settings. Your code needs to respect that. Ignoring it isn't just a nicety, it's a WCAG 2.1 AA failure.
@media (prefers-reduced-motion: reduce) {
.list-item {
animation: none;
/* Elements are visible immediately; no cascade */
}
}If you want to preserve a subtle fade (without any movement) for reduced-motion users, you can override just the keyframe rather than disabling animation entirely:
@media (prefers-reduced-motion: reduce) {
@keyframes fadeUp {
from { opacity: 0; }
to { opacity: 1; }
}
.list-item {
animation-delay: 0ms; /* no stagger — all fade together */
animation-duration: 200ms;
}
}One more consideration: don't stagger more than 8–10 items in a single visible group. Beyond that, the last element's delay can exceed 700ms, and users are sitting there watching a list assemble itself. That's annoying. Either cap the delay with min() in CSS or clamp the index in JS: delay: Math.min(i, 7) * step. The gradient generator and box shadow generator on Empire UI both use capped stagger for exactly this reason — the controls section would feel brutally slow otherwise.
Putting It Together: A Real-World Example
Here's a production-ready React component that combines the custom property approach with scroll triggering and prefers-reduced-motion respect. This is the kind of thing you'd use for a features grid, testimonial cards, or a blog index.
import { useEffect, useRef } from 'react';
interface StaggerGridProps {
items: { id: string; label: string }[];
}
export function StaggerGrid({ items }: StaggerGridProps) {
const ref = useRef<HTMLUListElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReduced) return; // CSS fallback handles it
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.classList.add('is-visible');
observer.disconnect();
}
});
},
{ threshold: 0.15 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<ul ref={ref} className="stagger-grid">
{items.map(({ id, label }, i) => (
<li
key={id}
className="stagger-grid__item"
style={{ '--i': Math.min(i, 7) } as React.CSSProperties}
>
{label}
</li>
))}
</ul>
);
}.stagger-grid__item {
opacity: 0;
transform: translateY(20px);
transition: none; /* wait for JS trigger */
}
.stagger-grid.is-visible .stagger-grid__item {
animation: fadeUp 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--i, 0) * 75ms);
}
@media (prefers-reduced-motion: reduce) {
.stagger-grid__item,
.stagger-grid.is-visible .stagger-grid__item {
opacity: 1;
transform: none;
animation: none;
}
}Is this more code than dropping in Framer Motion? Yes, by maybe 15 lines. But it ships zero JS runtime overhead for the animation itself, it tree-shakes to nothing, and it's readable by any dev who knows CSS. That tradeoff is worth it for components you ship to production at scale.
From here, the natural next step is wiring this up with more advanced patterns — exit animations, route transition stagger, scroll-linked progress. The Empire UI component library has several pre-built animated grid patterns ready to copy, including aurora-style and cyberpunk variants with staggered neon flickers built right in.
FAQ
60ms to 100ms per step works for most UI contexts. Below 50ms the cascade isn't perceptible; above 150ms per step it starts feeling sluggish, especially for lists with 6+ items.
Pass the array index as a CSS custom property via inline style: style={{ '--i': i } as React.CSSProperties}. Your CSS rule reads animation-delay: calc(var(--i) * 70ms) and everything just works regardless of list length.
No — :nth-child counts among all sibling elements of any type, so mixed children (e.g. a heading followed by list items) will throw the numbering off. Use :nth-of-type for same-tag siblings, or set --i via JavaScript instead.
For static or SSR-rendered lists, plain CSS with custom properties is faster to ship and has zero runtime cost. Reach for Framer Motion when you need exit animations, drag-reorder stagger, or spring physics — things CSS can't do.