9 hiệu ứng css scroll animation không cần JS
Css scroll animation là animation gắn liền với vị trí cuộn trang, chạy bằng animation-timeline của CSS thay cho một listener JS lắng nghe sự kiện scroll.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Trồi lên xuất hiện
- 02 Xuất hiện lần lượt
- 03 Lau chữ hiện ra
- 04 Rèm ảnh
- 05 Thanh tiến độ đọc
- 06 Header thu nhỏ
- 07 Parallax
- 08 Đếm số tăng dần
- 09 Timeline vẽ đường
Thứ tự chín hiệu ứng đi theo cách một trang tự lộ ra khi bạn cuộn xuống. Từ 01 đến 04 chạy một lần duy nhất, ngay khi phần tử đó vào khung nhìn. Từ 05 đến 07 đọc theo vị trí cuộn của toàn bộ trang thay vì của một phần tử riêng lẻ. 08 và 09 gộp cả hai kiểu lại, một điểm kích hoạt duy nhất nhưng được canh theo tiến độ cuộn xung quanh nó. Ở lưới phía trên, bạn xem từng hiệu ứng tự lặp lại; bản đầy đủ không cắt bớt dòng nào nằm trong file zip.
01Trồi lên xuất hiện
Phần tử này trồi nhẹ lên từ dưới rồi mờ dần hiện ra ngay khi vào khung nhìn, nhờ animation-timeline: view() thay cho một IntersectionObserver viết tay. Đây gần như là kiểu xuất hiện mặc định cho tiêu đề section, card và đoạn văn — bất cứ thứ gì chỉ cần báo hiệu một lần rồi lùi vào nền.
.card {
animation: fade-up $easing both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY($distance); }
to { opacity: 1; transform: translateY(0); }
}
02Xuất hiện lần lượt
Các mục trong danh sách hiện ra lần lượt khi bạn cuộn qua chúng, cách nhau khoảng 80ms, mà không cần một dòng setTimeout nào. Độ trễ đến từ chính cách các mục xếp theo chiều dọc trong layout, vì mỗi mục tự theo dõi thời điểm nó vào khung nhìn. Dùng cho danh sách tính năng, bảng giá, hoặc lưới giới thiệu team.
.list__item {
animation: stagger-in $ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY($distance); }
to { opacity: 1; transform: translateY(0); }
}
03Lau chữ hiện ra
Tiêu đề được lau hiện ra từ trái sang phải bằng một clip-path đang chuyển động, thay vì animate width hay left. Dùng cho tiêu đề hero và tiêu đề lớn của section, nơi chỉ cần một lần xuất hiện thật chắc chắn.
.headline {
animation: wipe-in $easing both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
@keyframes wipe-in {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
04Rèm ảnh
Một tấm màu phủ kín ảnh, rồi trượt sang phải khi khung ảnh cuộn vào khung nhìn, để lộ ảnh bên dưới. Hợp với portfolio và ảnh sản phẩm, nơi bản thân tấm màn che cũng là một phần của thiết kế.
.frame__curtain {
transform-origin: right;
animation: curtain-open $easing both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
@keyframes curtain-open {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
05Thanh tiến độ đọc
Thanh cố định ở đầu trang lấp đầy theo mức bạn đã đọc, chỉ chạy bằng một scroll() timeline gắn với toàn bộ tài liệu, không cần listener tính lại mỗi frame. Hợp với bài viết dài và trang tài liệu, nơi người đọc muốn biết còn lại bao nhiêu.
.bar {
position: fixed;
top: 0;
left: 0;
transform-origin: left;
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
06Header thu nhỏ
Header dính (sticky) mỏng lại và logo nhỏ đi khi trang được cuộn, dùng transform: scaleY thay vì animate height. Dùng cho header của trang landing hay blog, nơi header cần ở lại trên cùng mà không chiếm hết màn hình.
.head {
position: sticky;
top: 0;
transform-origin: top;
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
@keyframes shrink-header {
from { transform: scaleY(1); }
to { transform: scaleY(.75); }
}
07Parallax
Nền phía sau trôi chậm hơn nội dung phía trước khi trang cuộn, và chính khoảng chênh lệch tốc độ giữa hai lớp đó tạo ra cảm giác chiều sâu. Dùng cho nền hero và những chỗ ngăn cách giữa các section.
.layer--back {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
.layer--front {
animation: drift-front linear both;
animation-timeline: scroll(root);
}
@keyframes drift-back {
from { transform: translateY(0); }
to { transform: translateY(-20px); }
}
@keyframes drift-front {
from { transform: translateY(0); }
to { transform: translateY(-40px); }
}
08Đếm số tăng dần
Con số đếm từ 0 đến giá trị đích ngay khi vừa hiện ra, nhờ @property đăng ký biến tuỳ chỉnh là kiểu integer để trình duyệt nội suy số nguyên thay vì số thập phân. Không có dòng JavaScript nào đọc giá trị này — bộ đếm CSS tự làm việc đó. Dùng cho section thống kê, những nơi một con số đứng yên trông vô hồn.
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat__num {
--num: 0;
counter-reset: num var(--num);
animation: count-up 1.2s linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
&::after { content: counter(num); }
}
@keyframes count-up {
from { --num: 0; }
to { --num: 1280; }
}
09Timeline vẽ đường
Một đường thẳng đứng tự vẽ dần khi bạn cuộn trang, và mỗi chấm tròn sáng lên ngay khi đường đã vẽ qua nó. Dùng cho lịch sử phát triển, hướng dẫn từng bước, hoặc roadmap — những nơi chính tiến độ mới là điều cần truyền tải.
.timeline__line {
transform-origin: top;
animation: line-draw linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
.timeline__dot {
animation: dot-light $duration ease-out both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
@keyframes line-draw {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@keyframes dot-light {
from { opacity: .28; }
to { opacity: 1; }
}
Chỗ dễ hỏng — cái bẫy nằm ở đâu?
Cả chín hiệu ứng đều dùng animation-timeline có sẵn trong CSS thay vì một trình lắng nghe sự kiện scroll, nên không có gì phải chạy trong JavaScript ở mỗi frame cuộn, đúng như hướng dẫn scroll-driven animations của MDN mô tả. Cái bẫy đầu tiên là mức hỗ trợ trình duyệt: animation-timeline chạy được từ Chrome 115, từ Safari 26, còn Firefox bản ổn định tính đến 9/2026 vẫn cần bật cờ thử nghiệm mới dùng được — bảng chi tiết nằm ở caniuse. Vì vậy mỗi file đều bọc rule chuyển động trong @supports (animation-timeline: view()) cùng một khối @supports not đi kèm, phần dự phòng hiển thị luôn trạng thái đã hoàn tất, và đó chính là khác biệt giữa một demo xuống cấp tử tế với một demo cứ vô hình mãi trên trình duyệt cũ.
Cái bẫy thứ hai nằm ở animation-range: 0 200px của mục 06. Đó là một mốc pixel cố định chứ không phải phần trăm của trang, nên header chỉ thu nhỏ xong sau khi bạn đã cuộn qua đúng 200px kể từ đầu trang, và nếu đặt con số đó lớn hơn cả chiều cao có thể cuộn của trang thật thì header sẽ kẹt lại giữa chừng mãi mãi. Những con số này đã được canh theo đúng các trang demo thật rồi, và nếu bạn muốn dừng đọc để mở file ngay thì mật khẩu của zip là x5xz5uh4.
Cái bẫy thứ ba nằm ngay ở phần xem trước phía trên: lưới đó đặt trong một khung iframe cố định 480×300, quá thấp để chứa một cú cuộn thật, nên mỗi ô tự chuyển sang một vòng lặp .is-demo dài hai giây thay cho scroll-timeline thật của nó. Bạn chỉ xem chuyển động lặp ở đây thôi; muốn thấy thanh của mục 05 hay đường của mục 09 phản hồi đúng theo vị trí cuộn thật, hãy mở riêng file vanilla trong tab của bạn rồi tự tay cuộn trang đó — vòng lặp trong lưới chỉ là bản thế chỗ tạm thời.
Khả năng tiếp cận
Chín demo đều phản hồi prefers-reduced-motion, nhưng cách xử lý không phải lúc nào cũng là tắt hẳn chuyển động — đôi khi là nhảy thẳng đến kết quả cuối cùng. Từ 01 đến 04 bỏ animation và hiện luôn trạng thái đã xong: opacity về 1, chữ đã lau xong hoàn toàn, rèm đã kéo hết sang một bên. Mục 08 đặt --num thẳng về số đích thay vì đếm dần từng số, còn mục 09 vẽ hết đường và sáng hết mọi chấm mà không có bước quét nào cả. Mục 06 và 07 cũng khoá luôn ở trạng thái cuối, vì một header đã thu nhỏ sẵn hay các lớp parallax đứng yên không mang đủ tính trang trí để đáng giữ lại khi chuyển động đã tắt. Hướng dẫn prefers-reduced-motion của MDN có ghi mức hỗ trợ trình duyệt cho media query này.
@media (prefers-reduced-motion: reduce) {
.card, .list__item, .headline, .frame__curtain { animation: none !important; }
.head, .timeline__line { animation: none !important; transform: scaleY(1); }
.stat__num { animation: none !important; --num: 1280; }
}
Xem thêm hiệu ứng CSS scroll khác trong chuyên mục CSS, và trang giới thiệu nói rõ site này thực chất là gì.
FAQ
Css scroll animation có cần JavaScript không?
Không. Cả chín hiệu ứng ở đây đều chạy bằng animation-timeline: view() hoặc scroll(), tức là một thuộc tính CSS chứ không phải một scroll listener. Thư mục react/ trong zip chỉ bọc cùng một đoạn SCSS vào component, chứ không dùng JS để điều khiển chuyển động thật.
view() và scroll() khác nhau ở chỗ nào?
view() canh animation theo hành trình của riêng một phần tử khi nó đi qua khung nhìn, nên các mục 01 đến 04, mục 08 và các chấm ở mục 09 dùng nó. scroll() thì đọc theo mức cuộn của toàn bộ tài liệu, và đó là thứ chạy ở mục 05, 06, 07 và đường kẻ ở mục 09. Chọn view() khi bạn animate một phần tử vào đúng vị trí của nó, còn chọn scroll() khi chuyển động cần bám theo tiến độ cuộn tổng thể của trang.
Có dùng chung với Tailwind hoặc một dự án React được không?
Được. Class tiện ích của Tailwind và đoạn SCSS này không xung đột với nhau, vì chuyển động nằm trọn trong animation-timeline và keyframes chứ không nằm trong class mà Tailwind cũng muốn quản lý. Thư mục react/ trong zip có một component cho mỗi hiệu ứng, nhận đúng những biến số đó qua props, nên bạn không cần viết lại gì để dùng trong một thư viện component.