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
- 01 Thanh mỏng phía trên
- 02 Huy hiệu vòng tròn
- 03 Chấm điều hướng theo section
- 04 Thanh dọc bên cạnh
- 05 Chữ được tô đầy dần
- 06 Số phần trăm
- 07 Bước theo chương
- 08 Mục lục được làm nổi bật
- 09 Thời gian đọc còn lại
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
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. 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
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. 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
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 đó. 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
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. 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
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ả. 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
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. 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
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. 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
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. 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
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. 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ợ 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+ |
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
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.