GODRICH
#css-animation#button#loader#transition#scroll#ui#xu-huong-thiet-ke

9 css timeline animation không JS, copy-paste

Css timeline animation là cách nối nhiều bước bằng đường kẻ với vòng tròn để bày ra theo thứ tự.

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

Chín mẫu timeline html css này xếp theo thứ tự một màn hình thật sự trải qua: trước hết là cách bản thân timeline được vẽ ra (01~02), rồi cách stepper nằm ngang bày tiến độ (03~04), tiếp đến cách nó phủ lên màn hình như nội dung (05~06), và cuối cùng là cách chỉ một tính năng CSS mới lo hết chuyện cuộn với thứ tự (07~09).

01Vẽ đường dọc

Timeline css design dùng vào đâu?

Ở danh sách sự kiện xếp dọc như lịch sử công ty hay kinh nghiệm trong CV, đường kẻ mọc dần từ trên xuống dưới.

Vẽ đường dọc
Mở trong tab mới

01 · Vẽ đường dọc — Diện tích đo được 0.368% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleYtransform-origin1.4s

Cách làm

Đi từ transform: scaleY(0) tới scaleY(1), chỉ cần thêm transform-origin: top là xong.

.rail {
  width: 3px;
  height: 100%;
  background: $color;
  transform-origin: top;
  transform: scaleY(1);

  @supports (animation-timeline: view()) {
    transform: scaleY(0);
    animation: draw linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 70%;
  }
}
@keyframes draw {
  from { transform: scaleY(0); }
  to   { transform: scaleY(1); }
}

02Chấm bật lên lần lượt

Chuyện gì xảy ra

Các chấm trên đường kẻ nảy lên từng cái một, bắt đầu từ chấm trên cùng.

Chấm nảy lần lượt
Mở trong tab mới

02 · Chấm nảy lần lượt — Diện tích đo được 4.049% · khung hình chuyển động 6/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleanimation-delaynth-child

Lý do làm vậy

Dùng cho checklist onboarding hay bảng hiện bước xử lý đơn hàng, mỗi chấm chỉ khác nhau delay đặt qua nth-child.

.dot {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: $color;
  transform: scale(0);
  opacity: 0;
  animation: pop 2s cubic-bezier(.2, 1.4, .4, 1) both;
}

@for $i from 1 through 5 {
  .dot:nth-child(#{$i}) { animation-delay: #{($i - 1) * 140}ms; }
}

@keyframes pop {
  0%   { transform: scale(0);    opacity: 0; }
  60%  { transform: scale(1.25); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}

03Đổ đầy bước nằm ngang

Trên màn hình

Ở chuỗi bước nằm ngang kiểu đăng ký thành viên hay khảo sát, đường nối giữa các bước được tô màu đúng bằng quãng đã đi qua.

Tô màu các bước ngang
Mở trong tab mới

03 · Tô màu các bước ngang — Diện tích đo được 1.597% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleXflex: 1 1 0600ms

Cốt lõi khi triển khai

scaleX phóng to theo chính chiều rộng của nó, nên bạn khỏi cần biết ô bên cạnh rộng bao nhiêu.

.bar {
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 0;
  height: 3px;
  overflow: hidden;
}
.fill {
  width: 100%;
  height: 100%;
  background: $color;
  transform: scaleX(0);
  transform-origin: left;
}
.step.is-done + .bar .fill { transform: scaleX(1); }

04Nhịp đập ở bước đang chạy

Nhìn thế nào

Giống đơn hàng đang được xử lý hay badge báo realtime, phía sau vòng tròn của bước đang chạy có một vòng phình ra rồi nhạt dần, lặp mãi.

Bước đang chạy đập nhịp
Mở trong tab mới

04 · Bước đang chạy đập nhịp — Diện tích đo được 2.033% · khung hình chuyển động 13/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleopacity1.6s

Điểm mấu chốt

Nó tách riêng đúng một tín hiệu "chỗ này đang chạy", giống vệt radar quét qua.

.ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid $color;
  animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse {
  0%   { transform: scale(.7); opacity: .8; }
  70%  { transform: scale(1.8); opacity: 0; }
  100% { transform: scale(1.8); opacity: 0; }
}

05Thẻ trượt so le hai bên

Dùng khi nào

Trên trang mốc dự án hay lịch sử công ty, lấy đường dọc giữa làm trục, thẻ lẻ dồn vào từ trái còn thẻ chẵn dồn vào từ phải.

Card trượt so le hai bên
Mở trong tab mới

05 · Card trượt so le hai bên — Diện tích đo được 8.283% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

translateXopacity500ms

Điểm cần ghi nhớ

Mỗi thẻ lọt vào viewport sẽ tự khởi động nhờ view().

.row--l .card { transform: translateX(-24px); }
.row--r .card { transform: translateX(24px); }

@supports (animation-timeline: view()) {
  .card {
    opacity: 0;
    animation: in ease-out both;
    animation-timeline: view();
    animation-range: entry 0% cover 60%;
  }
}
@keyframes in { to { opacity: 1; transform: translateX(0); } }

06Stepper thanh toán

Màn hình nào

Trong bốn bước của luồng thanh toán thương mại điện tử hay form đặt chỗ, bước nào xong thì con số trong vòng tròn đổi thành icon dấu check.

Stepper thanh toán
Mở trong tab mới

06 · Stepper thanh toán — Diện tích đo được 0.77% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

stroke-dashoffsetscaleXSVG

Cách tạo

Thay vì ghép hai thanh thành nét chéo, cách này giảm dần stroke-dashoffset của polyline trong SVG để vẽ ra, nên điểm bắt đầu không bao giờ lệch mà vỡ hình.

.check polyline {
  stroke-dasharray: 16;
  stroke-dashoffset: 16;
}
.step.is-checked .check polyline {
  animation: draw .5s ease-out forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }

.step.is-checked + .bar .fill { transform: scaleX(1); }

07Vẽ đường tiến độ theo cuộn bằng scroll()

Cái gì chuyển động

Ở thanh tiến độ đọc của bài dài hay timeline bên lề tài liệu, cuộn bên trong hộp tới đâu thì đường dọc cạnh đó được vẽ ra đúng tới đó.

Vẽ đường tiến trình theo scroll(scroll())
Mở trong tab mới

07 · Vẽ đường tiến trình theo scroll(scroll()) — Diện tích đo được 0.373% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

animation-timeline: scroll()scaleYJS 0줄

Cách đọc code

Cả phần css animation timeline scroll ở đây gói vào một dòng animation-timeline: scroll(nearest block), thay cho listener sự kiện scroll lẫn vòng lặp requestAnimationFrame.

.rail {
  width: 3px;
  height: 100%;
  background: $color;
  transform-origin: top;
  transform: scaleY(1);

  @supports (animation-timeline: scroll()) {
    transform: scaleY(0);
    animation: draw linear both;
    animation-timeline: scroll(nearest block);
  }
}
@keyframes draw { to { transform: scaleY(1); } }

08Lộ sự kiện bằng view()

Dùng ở đâu

Trên timeline sự kiện hiện dần theo cuộn hay các phần của bài viết dài, không cần IntersectionObserver: mỗi thẻ tự cho opacity với translateY chạy đúng bằng quãng nó lọt vào màn hình.

Sự kiện hiện dần bằng view()
Mở trong tab mới

08 · Sự kiện hiện dần bằng view() — Diện tích đo được 29.524% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

animation-timeline: view()animation-rangeJS 0줄

Điểm chính của code

.card {
  opacity: 1;

  @supports (animation-timeline: view()) {
    opacity: 0;
    transform: translateY(16px);
    animation: reveal ease-out both;
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
  }
}
@keyframes reveal { to { opacity: 1; transform: translateY(0); } }

09Đánh số tự động bằng sibling-index()

Điều bạn thấy

Ở danh sách hay thêm bớt phần tử như checklist kéo thả đổi thứ tự, thứ tự anh em được dùng thẳng làm số thứ tự mà không cần quy tắc nth-child nào.

Đánh số tự động bằng sibling-index()
Mở trong tab mới

09 · Đánh số tự động bằng sibling-index() — Diện tích đo được 4.478% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

sibling-index()counter-setcalc()

Nguyên lý hoạt động

Mấu chốt nằm ở chỗ counter-set nhận được cả một biểu thức tính toán.

.step {
  counter-increment: n;
  &::before { content: counter(n); }
}
@supports (z-index: calc(sibling-index())) {
  .step {
    counter-increment: none;
    counter-set: n calc(sibling-index());
    animation-delay: calc((sibling-index() - 1) * 120ms);
  }
}

Timeline cuộn dễ vỡ ở đâu — mấy cái bẫy

Vấn đề là gì?

Bộ ba animation-timeline: scroll(), view() và sibling-index() giao hẳn phần timeline animation cho CSS lo, nhưng mức hỗ trợ của trình duyệt vẫn chênh nhau. Đây là số đo thật ngày 2026-09-10 trên bản Chrome 152 đã cài.

Tính năng Chrome·Edge Safari Firefox
animation-timeline scroll()·view() 115+ chưa có chưa có (sau cờ flag)
sibling-index()·sibling-count() 129+ chưa có chưa có

Tránh thế nào?

Phạm vi hỗ trợ kiểm tra lại ở tài liệu MDN về animation-timeline và bản nháp đặc tả sibling-index(). Lúc render demo cũng dính một cái bẫy thật. Script render xuất gif cho mấy demo này đẩy thẳng đồng hồ animation vào currentTime để rút từng khung hình, nhưng nhét giá trị thời gian tuyệt đối (ms) vào các animation dùng scroll() với view() như 07 và 08 thì cả lượt render chết hẳn, báo lỗi rằng animation chạy theo tiến độ không nhận thời gian tuyệt đối. Biết được giá trị đặt đồng hồ phải là CSS.percent(tiến độ) thì 07 với 08 mới chụp được bình thường. Mục 05 thẻ so le hai bên tuy render qua trót lọt nhưng dán vào dùng thật thì thẻ không bao giờ hiện, vì tên keyframe dành cho .is-demo lúc chụp khác với tên keyframe mà code dùng thật trỏ tới — lại một lần học rằng chụp qua không đồng nghĩa dùng thật đã ổn. Gỡ class .is-demo ra rồi render lại đúng điều kiện dùng thật, thấy opacity của thẻ về gần 1 chứ không phải 0, mới dám coi là đã sửa xong. Mật khẩu nén của chín file chứa đủ các giá trị này là q3b8jjma, và bên dưới bạn tải về trong một zip duy nhất. Trên Firefox với Safari, animation-timeline ở 07 và 08 bị bỏ qua, nên đường kẻ và thẻ đã được ghim sẵn ở trạng thái cuối vẽ xong bằng @supports not (animation-timeline: scroll()) (hoặc view()) — đoạn code đó nằm trong cả vanilla/ lẫn react/ của zip này.

Tiếp cận

Dưới reduced-motion còn lại gì?

Bật tùy chọn giảm chuyển động của hệ điều hành (prefers-reduced-motion: reduce) thì cả chín mẫu dừng chuyển động. Những mục được đổ đầy hay vẽ ra như 01, 03, 06, 07, 08 bị ghim ở trạng thái cuối đã đầy chứ không phải tiến độ 0; còn những mục hiện ra từng cái như 02, 05, 09 bị ghim ở trạng thái đã hiện hết. Vòng nhịp đập của 04 chỉ còn opacity: .6 cố định, hết chớp tắt nhưng vẫn giữ tín hiệu "đang ở đây". Icon check của 06 là thông tin trạng thái chứ không phải trang trí, nên vòng tròn được gắn riêng aria-label="hoàn thành"; con số vẽ bằng counter() ở mục 09 thì screen reader không đọc được, nên <li> bọc ngoài mang thêm aria-label="bước thứ {n}". Phạm vi hỗ trợ của prefers-reduced-motion xem ở tài liệu MDN.

Bài nên đọc tiếp

Bộ khác cũng dùng trigger cuộn này nằm ở 9 css progress bar animation, còn toàn bộ chủ đề CSS animation gom trong chuyên mục CSS animation.

FAQ

Trình duyệt không chạy được scroll()·view() thì timeline đứng yên à?

Cả vanilla/ lẫn react/ đều có nhánh @supports not (animation-timeline: scroll()) (mục 08 là view()), nên nó dừng ở dáng đã vẽ xong mà không có phần tiến độ — hai bản hành xử giống nhau. Muốn 07 và 08 phản ứng realtime theo cuộn trên Firefox với Safari, bạn phải tự thêm JS tính tiến độ bằng sự kiện scroll hoặc IntersectionObserver rồi truyền qua biến CSS; điều kiện đó đã ghi trong mục "dòng thêm khi đặt làm bản React" của prompt.md.

Thay sibling-index() ở mục 09 bằng nth-child thường có được không?

Danh sách tĩnh thì nth-child là đủ. sibling-index() chỉ cần khi danh sách đổi thứ tự bằng kéo thả hoặc bị thêm bớt phần tử — quy tắc nth-child phải viết sẵn đủ theo số phần tử, còn sibling-index() đọc thẳng thứ tự anh em đang có trong DOM nên khỏi viết lại quy tắc.

Timeline dọc và ngang đặt chung một trang có sao không?

Nhóm chảy dọc như 01, 02, 05, 07, 08 và nhóm chảy ngang như 03, 04, 06 thường giữ vai trò khác nhau trong một trang: dọc dành cho nội dung chính (lịch sử, sự kiện), ngang dành cho phần hiện tiến độ ở đầu form. Trộn hai hướng trong cùng một khu của màn hình sẽ làm người đọc lẫn lộn đâu mới là "đang chạy", nên tốt nhất tách khu ra mà dùng.

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.