GODRICH

9 CSS Stagger Animation Demos Copy-Paste Là Chạy

CSS stagger animation là kỹ thuật khiến các phần tử trong danh sách hay lưới xuất hiện trễ nhau, thay vì bung ra cùng lúc.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Chín demo xếp theo thứ tự cách tính độ trễ ngày càng tự động hơn, không phải ngẫu nhiên. 01–03 là kiểu dáng cơ bản cho danh sách, lưới và chữ; 04–06 đổi hướng và hình dạng; 07–09 là ba cách tính giá trị delay khác hẳn nhau. Đoạn code bên dưới chỉ là phần trích của cách tính delay cốt lõi, còn file chạy đầy đủ nằm trong file nén của bài này.

01Cascade danh sách

Ba dòng trong .cascade chờ ẩn đi và thu nhỏ nhẹ, rồi mỗi dòng bắt đầu animation trễ nhau 90ms nhờ nth-child cộng vào animation-delay. Kiểu này có chiều sâu hơn một fade phẳng, hợp với feed thông báo hay danh sách kết quả tìm kiếm liên tục có mục mới.

scale+translateY90ms 간격ease-pop
.cascade li {
  opacity: 0;
  transform: translateY(14px) scale(.9);
  animation: cascade-loop $duration $easing;
}
@for $i from 1 through 3 {
  .cascade li:nth-child(#{$i}) {
    animation-delay: ($i - 1) * $stagger;   // $stagger: 90ms
  }
}
@keyframes cascade-loop {
  to { opacity: 1; transform: translateY(0) scale(1); }
}

02Sóng lưới (grid wave)

Chín thẻ xếp thành lưới 3×3, và delay của mỗi thẻ bằng tổng chỉ số hàng và chỉ số cột, nhân với 70ms. Thẻ ở góc trên trái bung ra trước, góc dưới phải bung ra cuối cùng như một đợt sóng chéo — hợp với lưới thẻ dashboard hay thumbnail gallery.

행+열 인덱스70ms 계수대각선 확산
$cols: 3;
@for $i from 1 through 9 {
  $row: math.floor(math.div($i - 1, $cols));
  $col: ($i - 1) % $cols;
  .grid .cell:nth-child(#{$i}) {
    animation-delay: ($row + $col) * $wave;   // $wave: 70ms
  }
}
@keyframes wave-loop {
  from { opacity: 0; transform: scale(.5); }
  to   { opacity: 1; transform: scale(1); }
}

03Stagger theo từng chữ

Mỗi chữ trong tiêu đề hero nằm trong một span riêng, và nth-child trễ từng chữ 30ms so với chữ trước nó. Khoảng trễ ngắn hơn hẳn hai mục trên khiến cả cụm chữ trông như nảy lên gần như liền mạch — hợp với tiêu đề hero hay animation intro logo.

글자별 span30ms 간격translateY 바운스
.letters span {
  opacity: 0;
  transform: translateY(16px);
  animation: letter-loop $duration $easing;
}
@for $i from 1 through 5 {
  .letters span:nth-child(#{$i}) {
    animation-delay: ($i - 1) * $stagger;   // $stagger: 30ms
  }
}
@keyframes letter-loop {
  to { opacity: 1; transform: translateY(0); }
}

04Lật thẻ 3D tuần tự

Ba thẻ nằm nghiêng 90 độ trên trục Y bên trong một khối cha có perspective, rồi lần lượt mở ra cách nhau 150ms để lộ mặt trước. Nhờ perspective đặt trên phần tử cha, cú lật có chiều sâu thật chứ không chỉ là scale phẳng — hợp với so sánh thẻ giá hay thẻ thông số sản phẩm.

rotateY 90→0150ms 간격perspective
.cards { perspective: 600px; }
.card {
  opacity: 0;
  transform: rotateY(90deg);
  transform-style: preserve-3d;
  animation: flip-loop $duration $easing;
}
@for $i from 1 through 3 {
  .card:nth-child(#{$i}) {
    animation-delay: ($i - 1) * $stagger;   // $stagger: 150ms
  }
}
@keyframes flip-loop {
  to { opacity: 1; transform: rotateY(0deg); }
}

05Thoát theo thứ tự ngược

Ba mục vào theo thứ tự 01 rồi đến 03, nhưng lúc thoát lại theo chiều ngược — 03 biến mất trước, 01 biến mất sau cùng — vì mỗi mục dùng một @keyframes riêng với mốc thời gian lệch nhau. Cách này hợp với danh sách xác nhận xóa hay dọn một chồng thông báo toast, nơi cái đến sau nên biến mất trước.

역순 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); }
}

06Gợn sóng hình tròn

Chín chấm tròn xếp lưới 3×3, và delay mỗi chấm bằng khoảng cách tới ô trung tâm nhân với 90ms — bốn góc xa nhất (khoảng cách √2) trễ nhất, còn ô giữa nổ ra đầu tiên với delay 0. Kết quả trông như một vòng sóng lan dần từ tâm ra ngoài, hợp với hiệu ứng bấm nút like hay lưới icon thông báo.

중심 거리 delay동심원 확산scale+opacity
.ripple span {
  opacity: 0;
  transform: scale(.3);
  animation: ripple-loop $duration $easing;
}
// góc = √2, cạnh = 1, tâm = 0 — tính sẵn rồi gán cứng
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 {
  to { opacity: 1; transform: scale(1); }
}

07Biến --i + calc() một dòng

Mỗi phần tử chỉ mang một giá trị --i trong inline style — <li style="--i:0">, <li style="--i:1"> và cứ thế — còn phía CSS không có vòng lặp nào, chỉ một dòng calc(var(--i) * 80ms) tính hết. Không cần SCSS @for hay nth-child, nên dù số lượng phần tử tăng giảm cũng không cần build lại style, hợp với component danh sách dùng chung.

--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;
  animation-delay: calc(var(--i) * 80ms);   // xong trong một dòng
}
@keyframes i-loop {
  to { opacity: 1; transform: translateY(0); }
}

08Tự động trễ bằng sibling-index()

Sáu ô không mang biến --i nào và cũng không có vòng lặp nth-child, chỉ một dòng animation-delay: calc(sibling-index() * 100ms). Hàm sibling-index() tự đọc thứ tự anh em của chính phần tử đó, nên markup còn gọn hơn cả cách dùng --i ở mục 07, nhưng mới chỉ chạy trên Chrome/Edge 138 trở lên theo bảng hỗ trợ sibling-index() của caniuse.

sibling-index()반복문 없음Chrome/Edge 138+
.tiles span {
  opacity: 0;
  transform: scale(.5);
  animation: tile-loop $duration $easing;
  animation-delay: calc(sibling-index() * 100ms);
}
@keyframes tile-loop {
  to { opacity: 1; transform: scale(1); }
}

09Delay giả ngẫu nhiên (SCSS random())

Năm badge dùng hàm random(420) của SCSS để gán mỗi phần tử một độ trễ khác nhau trong khoảng 1–420ms. Giá trị này không đổi mỗi lần tải trang trong trình duyệt — nó chỉ được rút một lần khi sass biên dịch, rồi cố định luôn trong file style.css.

SCSS random()비선형 지연컴파일타임 고정
@for $i from 1 through 5 {
  .badge:nth-child(#{$i}) {
    animation-delay: random(420) * 1ms;
  }
}
@keyframes badge-loop {
  from { opacity: 0; transform: translateY(10px) scale(.9); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

Bẫy hay gặp — dễ vỡ ở đâu

01 đến 06 và 09 tính delay bằng nth-child hoặc giá trị tính sẵn, nên chỉ đúng khi số phần tử cố định ngay từ đầu. Nếu danh sách thêm bớt phần tử theo kiểu động thì phải build lại style hoặc chỉnh tay markup — đúng vấn đề mà giá trị --i inline ở 07 và sibling-index() ở 08 né được hoàn toàn. Trang :nth-child() của MDN nói rõ bộ chọn này tính theo cấu trúc DOM thật đang có trên trang, không phải theo thời điểm biên dịch.

Mục 09 còn một điểm dễ hiểu lầm khác: hàm random() chỉ chạy đúng một lần khi trình biên dịch sass tạo ra style.css, và 8dxrtqpt là mật khẩu mở file nén mà bên trong giữ nguyên đúng những giá trị cố định đó, nên load lại trang bao nhiêu lần thì thứ tự badge vẫn y hệt.

Muốn thứ tự badge đổi thật mỗi lần ghé trang thì CSS thuần không làm được — phải nhờ JS tính lại thuộc tính tùy chỉnh --delay ngay lúc trang tải. Trong chín demo, chỉ 07 và 08 gọn còn đúng một dòng CSS cho delay, còn bảy demo còn lại cũng không cần JS để chạy, nhưng "ngẫu nhiên thật mỗi lần tải" thì không phải việc của CSS.

Khả năng tiếp cận

Cả chín demo đều tắt animation khi gặp prefers-reduced-motion: reduce và hiện thẳng trạng thái cuối, nghĩa là toàn bộ nội dung xuất hiện ngay lập tức thay vì trễ dần từng phần tử. Với 08, trên trình duyệt chưa hỗ trợ sibling-index() thì toàn bộ tile cũng bung ra cùng lúc không delay — một kiểu suy giảm êm chứ không vỡ layout. Giá trị cấu hình đầy đủ nằm trong trang @media prefers-reduced-motion của MDN.

@media (prefers-reduced-motion: reduce) {
  li, span, .card, .badge { animation: none !important; opacity: 1; transform: none; }
}

Muốn xem thêm animation dạng chuyển cảnh hay xuất hiện khác thì ghé chuyên mục CSS, còn trang giới thiệu nói rõ site này thật ra bán gì.

FAQ

Có bắt buộc dùng JavaScript cho stagger animation không?

Không. Cả chín demo chỉ chạy bằng keyframesanimation-delay của CSS, tự lặp không cần script nào. Giá trị --i ở 07 cũng chỉ là inline style gán sẵn trong markup, còn sibling-index() ở 08 là hàm CSS thuần.

Nên chọn --i hay sibling-index()?

Nếu cần chạy đúng trên mọi trình duyệt ngay bây giờ thì --i kèm calc() ở 07 là lựa chọn an toàn. Nếu chỉ cần chạy trong công cụ nội bộ hay admin panel dùng Chrome/Edge 138 trở lên thì sibling-index() ở 08 giúp markup gọn hơn hẳn (bảng hỗ trợ trên caniuse).

Delay ngẫu nhiên ở mục 09 có đổi mỗi lần tải trang không?

Không. Hàm random() của SCSS chỉ chạy một lần khi sass biên dịch ra style.css, và giá trị đó cố định trong file. Muốn nó đổi thật mỗi lần ghé trang thì phải dùng JS tính lại thuộc tính tùy chỉnh --delay ngay lúc trang tải, CSS thuần không làm được điều đó.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.