Staggered & Sequenced CSS Animations — Delays, --index, and Choreography
Senior guide to choreographing CSS animations: staggering lists with custom-property delays, nth-child timing, sequencing multiple animations, and negative delays.
Animation is not just “make it move” — it is choreography. {Animation không chỉ là “cho nó chuyển động” — đó là sự dàn dựng.} A great reveal feels like a conductor cueing instruments one after another, not a crowd all shouting at once. {Một hiệu ứng reveal tốt giống như nhạc trưởng ra hiệu cho từng nhạc cụ lần lượt, không phải cả đám hét cùng lúc.}
This post is a senior-level tour of staggering and sequencing in pure CSS. {Bài này là một tour cấp senior về staggering và sequencing bằng CSS thuần.} We will cover the why, the core techniques, and the production details people forget — reduced motion, enter/leave coordination, and data-driven indices. {Ta sẽ đi qua lý do, các kỹ thuật cốt lõi, và những chi tiết production hay bị quên — reduced motion, phối hợp enter/leave, và index theo dữ liệu.}
Interactive demo — open full screen · Demo tương tác — mở toàn màn hình
Why stagger at all?
Stagger means each item in a group starts its animation a little later than the previous one. {Stagger nghĩa là mỗi item trong nhóm bắt đầu animation muộn hơn item trước một chút.} Instead of 12 cards fading in simultaneously, they cascade — and that cascade carries meaning. {Thay vì 12 card fade-in cùng lúc, chúng đổ xuống tuần tự — và sự tuần tự đó mang ý nghĩa.}
- List reveals: the eye follows the sequence top-to-bottom, reinforcing reading order. {List reveal: mắt đi theo trình tự từ trên xuống, củng cố thứ tự đọc.}
- Menus / dropdowns: items appearing in order feel responsive and intentional, not janky. {Menu/dropdown: item xuất hiện có thứ tự tạo cảm giác phản hồi nhanh và có chủ đích, không giật.}
- Grids / galleries: a diagonal or row-by-row wave turns a flat layout into something alive. {Grid/gallery: một làn sóng chéo hoặc theo hàng biến layout phẳng thành thứ gì đó sống động.}
The rule of thumb: stagger communicates structure and hierarchy. {Quy tắc: stagger truyền đạt cấu trúc và thứ bậc.} Use it where order matters; skip it where everything is equal and instant feedback is the goal. {Dùng nơi thứ tự quan trọng; bỏ qua nơi mọi thứ ngang nhau và phản hồi tức thì mới là mục tiêu.}
The core trick: --i + calc() delay
The cleanest stagger technique sets a per-item index as a custom property and multiplies it into animation-delay. {Kỹ thuật stagger gọn nhất là gán index cho từng item bằng custom property rồi nhân vào animation-delay.}
<ul class="stagger-list">
<li style="--i: 0">Deploy pipeline</li>
<li style="--i: 1">Run migrations</li>
<li style="--i: 2">Warm the cache</li>
<li style="--i: 3">Flip the flag</li>
</ul>
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-list li {
/* The whole effect is driven by one multiplication. */
animation: rise-in 380ms ease both;
animation-delay: calc(var(--i) * 60ms);
}
The magic is calc(var(--i) * 60ms). {Điểm mấu chốt là calc(var(—i) * 60ms).} Item 0 starts at 0ms, item 1 at 60ms, item 2 at 120ms — a clean linear cascade with a single line of CSS. {Item 0 bắt đầu ở 0ms, item 1 ở 60ms, item 2 ở 120ms — một cascade tuyến tính gọn gàng chỉ với một dòng CSS.}
Two senior details to internalize: {Hai chi tiết cấp senior cần nắm:}
animation-fill-mode: both(thebothkeyword in the shorthand) keeps the item invisible before its delay elapses and frozen on the final frame after. {both giữ item vô hình trước khi hết delay và đứng yên ở frame cuối sau đó.} Without it, items flash visible during their delay window. {Thiếu nó, các item sẽ lóe lên trong khoảng delay.}- Keep the step (here
60ms) small enough that the whole group finishes fast. {Giữ bước nhảy (ở đây 60ms) đủ nhỏ để cả nhóm kết thúc nhanh.} With 20 items × 60ms the last item starts at 1.14s — too slow. {20 item × 60ms thì item cuối bắt đầu ở 1.14s — quá chậm.}
Capping the total duration
For long lists, clamp the delay so the tail does not crawl. {Với list dài, clamp delay để phần đuôi không bò chậm.}
.stagger-list li {
animation: rise-in 380ms ease both;
/* Step is 50ms, but no item waits longer than 500ms. */
animation-delay: min(calc(var(--i) * 50ms), 500ms);
}
min() turns an unbounded cascade into a bounded one — the first ~10 items stagger, the rest snap in together. {min() biến cascade vô hạn thành có giới hạn — ~10 item đầu stagger, phần còn lại vào cùng lúc.}
Fixed delays with :nth-child()
When the count is small and static, you do not even need a custom property. {Khi số lượng nhỏ và cố định, bạn thậm chí không cần custom property.} Hardcode the delays with :nth-child(). {Hardcode delay bằng :nth-child().}
.menu li {
animation: rise-in 300ms ease both;
}
.menu li:nth-child(1) { animation-delay: 0ms; }
.menu li:nth-child(2) { animation-delay: 60ms; }
.menu li:nth-child(3) { animation-delay: 120ms; }
.menu li:nth-child(4) { animation-delay: 180ms; }
Trade-offs: {Đánh đổi:}
- Pro: zero inline styles, no JS, fully declarative. {Ưu: không inline style, không JS, hoàn toàn declarative.}
- Con: it does not scale — adding a 5th item silently breaks the rhythm. {Nhược: không scale — thêm item thứ 5 sẽ phá nhịp một cách âm thầm.}
You can fake scaling with :nth-child(n) arithmetic in some engines, but honestly, once the list is dynamic, reach for --i. {Bạn có thể giả lập scale bằng số học :nth-child(n) ở vài engine, nhưng thật ra khi list động thì hãy dùng —i.}
Sequencing multiple animations on one element
A single element can run several animations at once, each with its own duration, delay, and timing function. {Một element có thể chạy nhiều animation cùng lúc, mỗi cái có duration, delay và timing function riêng.} The animation properties accept comma-separated lists that line up by position. {Các thuộc tính animation nhận danh sách phân tách bằng dấu phẩy, khớp theo vị trí.}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide {
from { transform: translateX(-24px); }
to { transform: translateX(0); }
}
@keyframes glow {
50% { box-shadow: 0 0 0 4px rgba(200, 255, 0, 0.4); }
}
.card {
animation-name: fade, slide, glow;
animation-duration: 300ms, 300ms, 900ms;
animation-delay: 0ms, 0ms, 300ms; /* glow waits for entrance to finish */
animation-timing-function: ease, ease-out, ease-in-out;
animation-fill-mode: both, both, none;
}
Read it column by column: fade and slide play together for the entrance, then glow fires 300ms in — a two-phase sequence built from independent animations. {Đọc theo cột: fade và slide chạy cùng lúc cho phần vào, rồi glow kích hoạt ở mốc 300ms — một chuỗi hai pha dựng từ các animation độc lập.}
The key constraint: each comma-list must have a matching number of values, or CSS cycles the shorter list. {Ràng buộc quan trọng: mỗi danh sách phẩy phải có số giá trị khớp nhau, nếu không CSS sẽ lặp lại danh sách ngắn hơn.} Being explicit avoids surprises. {Ghi rõ ràng tránh bất ngờ.}
Chaining: animation-delay vs one multi-step @keyframes
There are two ways to express “do A, then B”. {Có hai cách diễn đạt “làm A, rồi B”.} Knowing when to use each is a senior judgment call. {Biết khi nào dùng cái nào là một quyết định cấp senior.}
Option 1 — chain with delay: two animations, the second delayed by the first’s duration. {Phương án 1 — chain bằng delay: hai animation, cái thứ hai delay bằng duration của cái đầu.}
.badge {
animation:
pop-in 240ms ease both,
wiggle 400ms ease 240ms both; /* starts exactly when pop-in ends */
}
Option 2 — one keyframe with multiple stops: encode the whole timeline in percentages. {Phương án 2 — một keyframe nhiều mốc: mã hóa toàn bộ timeline bằng phần trăm.}
@keyframes pop-then-wiggle {
0% { transform: scale(0.6); opacity: 0; }
40% { transform: scale(1); opacity: 1; } /* pop done at 40% */
60% { transform: rotate(-6deg); }
80% { transform: rotate(6deg); }
100% { transform: rotate(0); }
}
.badge {
animation: pop-then-wiggle 640ms ease both;
}
When to pick which: {Khi nào chọn cái nào:}
- Use multiple animations when the phases are conceptually separate, reusable, or animate different properties that you want to tune independently. {Dùng nhiều animation khi các pha tách biệt về mặt khái niệm, tái sử dụng được, hoặc animate các thuộc tính khác nhau mà bạn muốn tinh chỉnh độc lập.}
- Use one multi-step keyframe when the phases form a single tightly-coupled gesture and you want one duration to scale the whole thing. {Dùng một keyframe nhiều bước khi các pha tạo thành một cử chỉ gắn chặt và bạn muốn một duration co giãn cả khối.}
A subtle gotcha: chaining via delay animates the same property in two animations (e.g. both touch transform), and the second one wins only while it runs. {Một bẫy tinh tế: chain bằng delay mà cả hai animation đụng cùng thuộc tính (vd cùng transform), animation sau chỉ thắng khi nó chạy.} The handoff frame must match or you get a visible jump. {Frame bàn giao phải khớp nếu không sẽ thấy giật.}
Negative delays: start mid-animation
A negative animation-delay does not wait — it starts the animation as if it had already been running for that long. {animation-delay âm không chờ — nó bắt đầu animation như thể đã chạy được khoảng thời gian đó.}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 2s linear infinite;
/* Each dot starts at a different point of the same loop. */
}
.spinner:nth-child(1) { animation-delay: 0s; }
.spinner:nth-child(2) { animation-delay: -0.5s; }
.spinner:nth-child(3) { animation-delay: -1s; }
.spinner:nth-child(4) { animation-delay: -1.5s; }
This is the canonical way to build phase-offset loops — loading spinners, pulsing dots, equalizer bars — where every element shares one keyframe but sits at a different point in the cycle. {Đây là cách kinh điển để dựng loop lệch pha — spinner loading, dấu chấm nhấp nháy, thanh equalizer — nơi mọi element dùng chung một keyframe nhưng ở điểm khác nhau trong chu kỳ.}
Because the negative delay maps directly to “seconds into the loop”, you can compute it with --i too. {Vì delay âm ánh xạ trực tiếp tới “số giây trong loop”, bạn cũng có thể tính nó bằng —i.}
.wave-bar {
animation: bounce 1s ease-in-out infinite;
animation-delay: calc(var(--i) * -0.1s);
}
animation-composition: add, don’t replace
By default, when two animations touch the same property, the last one replaces the others. {Mặc định, khi hai animation đụng cùng thuộc tính, cái cuối thay thế các cái khác.} animation-composition changes how a keyframe value combines with the underlying value. {animation-composition thay đổi cách giá trị keyframe kết hợp với giá trị nền.}
.tile {
transform: translateX(var(--x)); /* underlying layout offset */
animation: nudge 500ms ease both;
animation-composition: add; /* keyframe transform ADDS to the base */
}
@keyframes nudge {
50% { transform: translateY(-6px); }
}
With add, the keyframe’s translateY is added on top of the base translateX, so you keep your layout offset and layer a hover bounce without overwriting it. {Với add, translateY của keyframe được cộng lên translateX nền, nên bạn giữ offset layout và thêm hiệu ứng bounce mà không ghi đè.}
The three values: {Ba giá trị:}
replace(default) — keyframe wins, base is ignored. {replace (mặc định) — keyframe thắng, bỏ qua nền.}add— keyframe is added to the base (great fortransform/filterlists). {add — keyframe cộng vào nền (tốt cho danh sách transform/filter).}accumulate— like add but merges into a single function of the same type. {accumulate — như add nhưng gộp vào một hàm cùng loại.}
Check support before relying on it in critical paths — it is well supported in modern engines but still worth a @supports guard for older targets. {Kiểm tra hỗ trợ trước khi phụ thuộc ở đường quan trọng — nó đã được hỗ trợ tốt ở engine hiện đại nhưng vẫn nên có @supports cho target cũ.}
Coordinating enter and leave for lists
Enter staggers are easy. {Stagger lúc vào thì dễ.} The hard part is leaving — when items unmount, CSS animations alone cannot delay DOM removal. {Phần khó là lúc rời đi — khi item unmount, riêng CSS animation không thể trì hoãn việc xóa khỏi DOM.} A common pure-CSS pattern: reverse the stagger direction so the last item leaves first. {Một pattern CSS thuần phổ biến: đảo chiều stagger để item cuối rời trước.}
.list-item {
animation: rise-in 320ms ease both;
animation-delay: calc(var(--i) * 50ms);
}
/* When the parent gets .is-leaving, items animate out in reverse. */
.list.is-leaving .list-item {
animation: rise-out 240ms ease both;
/* (count - 1 - i) reverses the order; --count set by JS. */
animation-delay: calc((var(--count) - 1 - var(--i)) * 40ms);
}
@keyframes rise-out {
to {
opacity: 0;
transform: translateY(8px);
}
}
For real unmounting you still need JS to listen for animationend (or read the total computed delay) before removing nodes. {Để unmount thật bạn vẫn cần JS lắng nghe animationend (hoặc đọc tổng delay đã tính) trước khi xóa node.} The CSS defines the motion; JS defines the lifecycle. {CSS định nghĩa chuyển động; JS định nghĩa vòng đời.}
list.classList.add('is-leaving');
// Wait for the LAST item's animation, then remove.
list.addEventListener(
'animationend',
(event) => {
if (event.target === list.lastElementChild) list.remove();
},
{ once: true }
);
Data-driven stagger with a little JS
Hand-writing --i is fine for static markup, but real lists come from data. {Viết tay —i ổn cho markup tĩnh, nhưng list thật đến từ dữ liệu.} Set the index programmatically after render. {Gán index bằng code sau khi render.}
// Assign --i and --count once after the list mounts.
const items = document.querySelectorAll('.stagger-list > *');
items.forEach((el, index) => {
el.style.setProperty('--i', String(index));
});
document
.querySelector('.stagger-list')
?.style.setProperty('--count', String(items.length));
This keeps CSS in charge of how things move and JS responsible only for which index each node holds. {Cách này để CSS quản lý cách di chuyển và JS chỉ chịu trách nhiệm index nào cho mỗi node.} It is the smallest possible JS footprint — no animation logic leaks into scripts. {Đây là dấu chân JS nhỏ nhất có thể — không có logic animation rò rỉ vào script.}
For framework templates you usually inline the index directly, which is even cleaner. {Với template framework bạn thường inline index trực tiếp, còn gọn hơn.}
<!-- Astro / JSX-style loop pseudo-code -->
<ul class="stagger-list">
{items.map((item, i) => (
<li style={`--i: ${i}`}>{item.label}</li>
))}
</ul>
Respect prefers-reduced-motion
This is non-negotiable at senior level. {Điều này không thể thương lượng ở cấp senior.} Motion can trigger vestibular discomfort, so honor the user’s OS setting. {Chuyển động có thể gây khó chịu tiền đình, nên tôn trọng cài đặt OS của người dùng.}
@media (prefers-reduced-motion: reduce) {
.stagger-list li,
.card,
.badge {
/* Remove the cascade: show everything instantly. */
animation: none !important;
}
}
Note: this is one of the rare, defensible uses of !important — a global accessibility override that must beat every component-level animation. {Lưu ý: đây là một trong số ít trường hợp dùng !important hợp lý — một override accessibility toàn cục phải thắng mọi animation cấp component.} In a design-token codebase, prefer scoping it to a single utility class instead. {Trong codebase dùng design token, nên giới hạn nó vào một utility class duy nhất.}
A gentler alternative is to keep a tiny opacity fade but drop all movement and stagger. {Một giải pháp dịu hơn là giữ một fade opacity nhỏ nhưng bỏ hết chuyển động và stagger.}
@media (prefers-reduced-motion: reduce) {
.stagger-list li {
animation: fade 200ms ease both; /* no translate, no delay */
animation-delay: 0ms;
}
}
The principle: reduced-motion does not mean “no feedback”, it means “no large, surprising, repeated motion”. {Nguyên tắc: reduced-motion không phải “không phản hồi”, mà là “không chuyển động lớn, bất ngờ, lặp lại”.}
Putting it together: a grid wave
A 2D stagger uses the index to compute a diagonal delay so the reveal washes across the grid. {Một stagger 2D dùng index để tính delay chéo, làm hiệu ứng reveal quét ngang grid.}
<div class="grid" style="--cols: 4">
<!-- each cell: style="--i: <flatIndex>" -->
</div>
.grid {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 12px;
}
.grid > * {
/* Diagonal: row + column derived from flat index. */
--row: calc(var(--i) - (var(--col) * var(--cols)));
animation: rise-in 360ms ease both;
animation-delay: calc(var(--i) * 35ms);
}
For a true diagonal you would compute row + col in JS and feed a single --d (distance) value, then animation-delay: calc(var(--d) * 45ms). {Để có đường chéo thật, tính row + col trong JS và truyền một giá trị —d (khoảng cách), rồi animation-delay: calc(var(—d) * 45ms).} Keep the math in JS, keep the motion in CSS. {Giữ phép toán ở JS, giữ chuyển động ở CSS.}
Checklist before shipping
- Drive stagger with
--i+calc(); reserve:nth-child()for tiny static lists. {Dùng —i + calc() cho stagger; để dành :nth-child() cho list tĩnh nhỏ.} - Cap long cascades with
min()so the tail does not drag. {Giới hạn cascade dài bằng min() để đuôi không lê thê.} - Use comma-lists to sequence independent animations; use multi-step keyframes for one coupled gesture. {Dùng danh sách phẩy để sequence các animation độc lập; dùng keyframe nhiều bước cho một cử chỉ gắn chặt.}
- Reach for negative delays to phase-offset shared loops. {Dùng delay âm để lệch pha các loop dùng chung.}
- Always set
fill-mode: bothfor entrances so items do not flash. {Luôn đặt fill-mode: both cho phần vào để item không lóe.} - Reverse the index on leave; let JS own the unmount via
animationend. {Đảo index khi rời; để JS quản lý unmount qua animationend.} - Gate everything behind
prefers-reduced-motion. {Bọc mọi thứ sau prefers-reduced-motion.}
Choreography is the difference between an interface that works and one that feels designed. {Sự dàn dựng là khác biệt giữa giao diện chạy được và giao diện cảm giác được thiết kế.} Stagger and sequence with intent, measure the timing in milliseconds, and always give motion-sensitive users a calm path. {Hãy stagger và sequence có chủ đích, đo timing theo mili-giây, và luôn cho người nhạy cảm chuyển động một lối đi êm.}