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

9 CSS Scroll Progress Bar — không cần JS

CSS scroll progress bar là thanh, vòng hay con số cho biết bạn đã đọc được bao nhiêu phần trang, tính trực tiếp từ vị trí cuộn.

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

Chín kiểu này không xếp theo mức độ phổ biến mà theo thứ tự mắt bạn chạm tới trước. 01 đến 04 nằm ở mép màn hình, tự lọt vào mắt mà không cần nhìn thẳng vào đó. 05 và 06 nằm ngay trong nội dung bạn đang đọc. 07 đến 09 mô tả chính cấu trúc bài viết — chương, mục lục, thời gian còn lại. Cách mỗi kiểu đọc được vị trí cuộn nằm trong phần mô tả của từng mục dưới đây, còn bản thay thế cho trình duyệt chưa hỗ trợ animation-timeline: scroll() thì nằm ngay trong phần bẫy phía dưới.

01Thanh mỏng phía trên

Bạn thấy gì

Thanh 3px dán ở mép trên cùng màn hình lấp đầy từ trái sang phải đúng theo mức bạn đã cuộn, chỉ bằng animation-timeline: scroll(root) đặt trong @supports.

Thanh mỏng phía trên
Mở trong tab mới

01 · Thanh mỏng phía trên — Diện tích đo được 1.0% · 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

scroll()3px왼쪽 정렬

Dùng ở đâu

Vị trí quen thuộc nhất là ngay đầu bài blog dài hoặc dưới header của trang tài liệu.

.bar {
  width: 100%;
  transform: scaleX(0);
  transform-origin: left;

  @supports (animation-timeline: scroll()) {
    animation: fill linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill { to { transform: scaleX(1); } }

02Huy hiệu vòng tròn

Chuyện gì diễn ra trên màn hình

Huy hiệu tròn nổi ở góc dưới bên phải được tô đầy dần bằng một vòng conic-gradient, với biến --p đăng ký qua @property để trình duyệt nội suy mượt từng độ thay vì nhảy giật cục.

Huy hiệu vòng tròn
Mở trong tab mới

02 · Huy hiệu vòng tròn — Diện tích đo được 0.736% · 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

conic-gradient@property고정 위치

Chỗ dùng

Khi ghép chung với nút back-to-top nổi, một huy hiệu vừa dẫn đường vừa báo tiến độ đọc.

@property --p { syntax: '<number>'; initial-value: 0; inherits: false; }

.badge {
  --p: 0;
  border-radius: 50%;
  background: conic-gradient($color calc(var(--p) * 1%), $stage-paper 0);

  @supports (animation-timeline: scroll()) {
    animation: fill-ring linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill-ring { from { --p: 0; } to { --p: 100; } }

03Chấm điều hướng theo section

Chuyển động thế nào

Trong hàng chấm dọc xếp bên cạnh trang, chỉ chấm của section bạn đang đọc phóng to lên 1.6 lần và sáng lên, còn ở bản thật thì IntersectionObserver theo dõi section nào chiếm phần lớn khung nhìn để bật đúng chấm đó.

Chấm điều hướng theo section
Mở trong tab mới

03 · Chấm điều hướng theo section — Diện tích đo được 0.683% · khung hình chuyển động 21/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scale 1.6배점 6개세로 정렬

Dùng khi nào

Kiểu này hợp với trang kể chuyện dạng một trang hoặc thanh điều hướng section của trang landing.

const io = new IntersectionObserver((entries) => {
  const top = entries
    .filter((e) => e.isIntersecting)
    .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
  if (top) setActive(sections.indexOf(top.target));
}, { threshold: [0.3, 0.6] });
sections.forEach((el) => io.observe(el));

04Thanh dọc bên cạnh

Bạn thấy gì

Track dọc mỏng ở mép phải màn hình được lấp đầy từ trên xuống dưới bằng scaleY(0→1), dùng đúng kỹ thuật animation-timeline: scroll(root) như thanh trên nhưng xoay trục dọc.

Thanh dọc bên cạnh
Mở trong tab mới

04 · Thanh dọc bên cạnh — Diện tích đo được 0.833% · 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

scaleY우측 4px고정

Dùng ở đâu

Trang tài liệu hay lề phải của một bài blog kỹ thuật dài là nơi nó thường xuất hiện.

.rail__fill {
  height: 100%;
  transform: scaleY(0);
  transform-origin: top;

  @supports (animation-timeline: scroll()) {
    animation: fill-y linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes fill-y { to { transform: scaleY(1); } }

05Chữ được tô đầy dần

Chuyện gì diễn ra trên màn hình

Màu chữ tiêu đề chuyển từ xám sang màu chính đúng theo tỉ lệ đã đọc, vẽ bằng một linear-gradient được cắt qua background-clip: text nên không cần thêm phần tử phụ nào cả.

Chữ được tô đầy dần
Mở trong tab mới

05 · Chữ được tô đầy dần — Diện tích đo được 2.938% · 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

background-clip:textlinear-gradient두 색

Chỗ dùng

Hiệu ứng hợp với headline hero hay tiêu đề bài viết muốn nhấn mạnh.

@property --p { syntax: '<percentage>'; initial-value: 0%; inherits: false; }

.headline {
  --p: 0%;
  background-image: linear-gradient(90deg, $color var(--p), $dim var(--p));
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
}

06Số phần trăm

Chuyển động thế nào

Con số nhỏ ở góc trên bên phải đếm từ 0 lên 100 đúng theo mức cuộn, nhờ @property đăng ký kiểu integer nên biến chỉ nội suy ra số nguyên chứ không dừng ở số thập phân lưng chừng.

Số phần trăm
Mở trong tab mới

06 · Số phần trăm — Diện tích đo được 0.872% · khung hình chuyển động 22/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

@property정수 보간% 붙이기

Dùng khi nào

Khảo sát dài hay tutorial nhiều bước thường đặt số này ở góc màn hình.

@property --num { syntax: '<integer>'; initial-value: 0; inherits: false; }

.counter__num {
  --num: 0;
  counter-reset: num var(--num);
  &::before { content: counter(num) '%'; }

  @supports (animation-timeline: scroll()) {
    animation: count-up linear both;
    animation-timeline: scroll(root);
  }
}
@keyframes count-up { from { --num: 0; } to { --num: 100; } }

07Bước theo chương

Bạn thấy gì

Trong bốn vòng tròn đánh số chương xếp ngang, chỉ vòng của chương đang đọc đổi màu và phồng lên rồi xẹp lại, nhờ một @keyframes riêng cho từng chương, còn bản thật tính chương hiện tại bằng Math.floor(tỉ lệ cuộn × số chương) trong React.

Bước theo chương
Mở trong tab mới

07 · Bước theo chương — Diện tích đo được 3.558% · 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

원 채움챕터 4개scale 1.25배

Dùng ở đâu

Series tutorial hay bài hướng dẫn nhiều bước là nơi nó hợp nhất.

@keyframes fill-2 {
  0%, 25%    { background: transparent; transform: scale(1); }
  33%        { background: $color; transform: scale(1.25); }
  40%, 100%  { background: $color; transform: scale(1); }
}
.step:nth-child(2) .step__circle { animation: fill-2 linear both; }

08Mục lục được làm nổi bật

Chuyện gì diễn ra trên màn hình

Trong mục lục bên cạnh, một nền viên thuốc trượt theo translateY để chỉ bám đúng tiêu đề đang đọc, còn màu tô ban đầu vốn là một màu đặc, nhưng lại làm mất tương phản chữ ở khung hình chuyển giữa hai dòng, nên đã đổi sang tint nhạt 14% cộng viền trái 3px.

Mục lục được làm nổi bật
Mở trong tab mới

08 · Mục lục được làm nổi bật — Diện tích đo được 21.896% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

translateY 알약현재 항목만좌측 보더 3px

Chỗ dùng

Kiểu này hợp với mục lục cạnh trang tài liệu hoặc bài hướng dẫn dài kiểu scroll-spy.

.toc__pill {
  position: absolute;
  background: rgba(47, 109, 246, .14);
  border-left: 3px solid $color;
  animation: move $dur-loop ease-in-out infinite;
}
@keyframes move {
  0%, 10%  { transform: translateY(0); }
  25%, 35% { transform: translateY($row); }
}

09Thời gian đọc còn lại

Chuyển động thế nào

Con số trong cụm "còn N phút" giảm dần khi bạn cuộn, tính bằng cách lấy tổng số phút trừ đi phần tương ứng với tỉ lệ trang đã đọc, qua một biến --min kiểu integer.

Thời gian đọc còn lại
Mở trong tab mới

09 · Thời gian đọc còn lại — Diện tích đo được 1.545% · khung hình chuyển động 12/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

카운트다운분 단위scroll() 연동

Dùng khi nào

Header của bài viết dài hay bản web của newsletter là nơi con số đang đếm ngược này thường xuất hiện.

@property --min { syntax: '<integer>'; initial-value: 12; inherits: false; }

.time__num {
  --min: 12;
  counter-reset: min var(--min);
  &::before { content: counter(min); }
}
@keyframes count-down { from { --min: 12; } to { --min: 0; } }

Bẫy ở đâu — chỗ dễ hỏng nhất

Hỗ trợ trình duyệt

Hỗ trợ animation-timeline: scroll() vẫn chưa đều giữa các trình duyệt, theo tài liệu MDN về scroll-driven animations và bảng caniuse, thực đo ngày 2026-09-10 như sau.

Tính năng Chrome·Edge Safari Firefox
animation-timeline scroll() 115+ 26+ (2025-09) 158+, bản chính thức 2026-09 vẫn nằm sau cờ thử nghiệm
@property 85+ 16.4+ 128+

Thay thế bằng JS trên Firefox

Trên Firefox — trình duyệt chưa hỗ trợ animation-timeline: scroll() chính thức — sáu kiểu dùng kỹ thuật này (01, 02, 04, 05, 06, 09) đứng yên tại chỗ nếu không có gì thay thế. Vì vậy bản react/ trong zip tính lại cùng giá trị cho sáu kiểu đó bằng sự kiện scroll của window, nên chúng vẫn chạy đúng trên mọi trình duyệt kể cả Firefox; còn 03, 07 và 08 vốn đã chạy bằng JS ngay từ bản dùng thật — IntersectionObserver cho 03 và 08 vì hai kiểu đó theo dõi section chứ không theo toàn tài liệu, Math.floor theo tỉ lệ cuộn cho 07 — nên không phụ thuộc animation-timeline: scroll() và cũng chạy như nhau trên mọi trình duyệt. Một bẫy khác chỉ lộ ra khi dựng thật: với 07 bước theo chương, nếu chỉ đổi màu tức thời thì số khung hình thay đổi giữa hai lần đo chỉ còn bốn, dưới hẳn ngưỡng đo chuyển động — thêm một cú phồng scale(1.25) đúng lúc chuyển bước mới kéo số khung hình lên đủ để máy đo tính là có chuyển động thật. Chín file trong gói đã sửa theo đúng cách đó rồi, mật khẩu để mở gói nén là 3rmg234a, gõ đúng như nó hiện trên dòng chữ này, không thêm khoảng trắng nào.

Khả năng tiếp cận

Trạng thái khi tắt animation

Cả chín kiểu đều tắt animation khi prefers-reduced-motion: reduce được bật. Với những kiểu tính theo phần trăm đã đọc (01, 02, 04, 05, 06, 09), chúng không dừng ở khung hình đầu mà nhảy thẳng tới trạng thái đã đọc xong 100%, vì đó mới là thông tin người dùng cần thấy ngay lập tức; riêng 03 chấm section và 08 mục lục thì giữ nguyên ở mục đầu tiên, đúng với vị trí khi trang còn ở trên cùng. Con số được vẽ bằng counter() ở 06 và 09 là chỗ dễ bị bỏ sót nhất: trình đọc màn hình không đọc được nội dung counter(), nên bản thân con số phải mang aria-hidden còn phần tử bọc ngoài cần role="status" hoặc role="progressbar" cộng aria-valuenow riêng để giữ lại đúng thông tin đó. Riêng 08 mục lục vốn có thể bấm để nhảy tới, dùng khung <a href="#id"> thật nên vẫn cho phép đến đúng nơi bằng bàn phím dù animation đã tắt hẳn; còn các chấm ở 03 chỉ mang aria-hidden để trang trí, không phải link, nên tự chúng không thêm điểm dừng bàn phím nào. Phạm vi hỗ trợ của chính prefers-reduced-motion nằm trong tài liệu MDN.

Các kiểu hiệu ứng cuộn khác đang nằm trong 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

Trình duyệt chưa hỗ trợ scroll() thì hiển thị thế nào?

@supports (animation-timeline: scroll()) bọc quanh rule chuyển động của sáu kiểu dùng kỹ thuật này (01, 02, 04, 05, 06, 09), nên trình duyệt không hỗ trợ chỉ đơn giản bỏ qua khối đó chứ không báo lỗi gì cả — ba kiểu 03, 07, 08 vốn đã chạy bằng JS nên không cần khối này. Bản react/ trong zip tính cùng giá trị bằng sự kiện scroll của window cho sáu kiểu kia nên vẫn chạy đúng trên Firefox hay bất kỳ trình duyệt nào chưa theo kịp.

Có thể bật cùng lúc mấy kiểu trong chín kiểu này không?

Được, miễn vị trí không đè lên nhau — 01 thanh trên và 04 thanh cạnh nằm ở hai mép khác nhau nên hợp dùng cùng lúc. Nhưng 06 số phần trăm và 09 thời gian còn lại cùng chiếm góc trên bên phải, vì vậy chỉ nên chọn một — bật cả hai chỉ khiến mắt phân vân xem đọc số nào trước.

Bài cần bao nhiêu H2 mới nên dùng kiểu 08 mục lục?

Dưới bốn H2 thì viên thuốc gần như không di chuyển đủ xa để người đọc để ý, nên 08 chỉ đáng dùng khi bài có ít nhất bốn tiêu đề mục trở lên. Bài ngắn hơn thì thanh 01 ở đầu trang đã đủ báo tiến độ rồi.

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.