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 không chỉ là “cho nó chuyển động” — đó là sự dàn dựng. 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.
Bài này là một tour cấp senior về staggering và sequencing bằng CSS thuần. 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 nghĩa là mỗi item trong nhóm bắt đầu animation muộn hơn item trước một chút. 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 reveal: mắt đi theo trình tự từ trên xuống, củng cố thứ tự đọc.
- 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.
- 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.
Quy tắc: stagger truyền đạt cấu trúc và thứ bậc. 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
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);
}
Điểm mấu chốt là calc(var(—i) * 60ms). 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.
Hai chi tiết cấp senior cần nắm:
- both giữ item vô hình trước khi hết delay và đứng yên ở frame cuối sau đó. Thiếu nó, các item sẽ lóe lên trong khoảng delay.
- Giữ bước nhảy (ở đây 60ms) đủ nhỏ để cả nhóm kết thúc nhanh. 20 item × 60ms thì item cuối bắt đầu ở 1.14s — quá chậm.
Capping the total duration
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() 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()
Khi số lượng nhỏ và cố định, bạn thậm chí không cần custom property. 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; }
Đánh đổi:
- Ưu: không inline style, không JS, hoàn toàn declarative.
- Nhược: không scale — thêm item thứ 5 sẽ phá nhịp một cách âm thầm.
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
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. 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;
}
Đọ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.
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. Ghi rõ ràng tránh bất ngờ.
Chaining: animation-delay vs one multi-step @keyframes
Có hai cách diễn đạt “làm A, rồi B”. Biết khi nào dùng cái nào là một quyết định cấp senior.
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 */
}
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;
}
Khi nào chọn cái nào:
- 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.
- 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.
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. Frame bàn giao phải khớp nếu không sẽ thấy giật.
Negative delays: start mid-animation
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; }
Đâ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ỳ.
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
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 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); }
}
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 đè.
Ba giá trị:
- replace (mặc định) — keyframe thắng, bỏ qua nền.
- add — keyframe cộng vào nền (tốt cho danh sách transform/filter).
- accumulate — như add nhưng gộp vào một hàm cùng loại.
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
Stagger lúc vào thì dễ. 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. 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);
}
}
Để 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. 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
Viết tay —i ổn cho markup tĩnh, nhưng list thật đến từ dữ liệu. 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));
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. Đây là dấu chân JS nhỏ nhất có thể — không có logic animation rò rỉ vào script.
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
Điều này không thể thương lượng ở cấp senior. 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;
}
}
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. Trong codebase dùng design token, nên giới hạn nó vào một utility class duy nhất.
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;
}
}
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
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);
}
Để 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). Giữ phép toán ở JS, giữ chuyển động ở CSS.
Checklist before shipping
- Dùng —i + calc() cho stagger; để dành :nth-child() cho list tĩnh nhỏ.
- Giới hạn cascade dài bằng min() để đuôi không lê thê.
- 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.
- Dùng delay âm để lệch pha các loop dùng chung.
- Luôn đặt fill-mode: both cho phần vào để item không lóe.
- Đảo index khi rời; để JS quản lý unmount qua animationend.
- Bọc mọi thứ sau prefers-reduced-motion.
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ế. 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.