CSS Scroll-Driven Animations — Compositor-Native Scroll UX Without JavaScript (2026)
Deep guide to animation-timeline, scroll() and view() timelines, animation-range, named timelines, performance vs JS listeners, a11y, and @supports fallbacks.
Vì sao scroll listener vẫn giật năm 2026
Mọi senior frontend đều từng ship tính năng gắn scroll: thanh tiến độ đọc, parallax hero, nav chapter sticky, hoặc card fade in khi cuộn. stack kinh điển là window.addEventListener('scroll', …), IntersectionObserver, hoặc vòng requestAnimationFrame đọc scrollTop rồi ghi inline style.
Pattern đó có ba vấn đề cấu trúc:
- Gắn main thread — scroll event chạy trên main thread. Long task chặn thread → handler trễ, animation giật.
- Đọc/ghi lẫn lộn — đo layout trong scroll handler buộc layout đồng bộ. Làm cho hàng chục element mỗi frame là anti-pattern.
- Timing dễ vỡ — debounce che giật;
passive: truegiúp delivery nhưng không giảm chi phí handler. Thư viện như Locomotive Scroll hay GSAP ScrollTrigger thêm kilobyte và vẫn chạy logic trên main thread.
CSS Scroll-Driven Animations đưa chuyển động gắn scroll lên compositor. trình duyệt map vị trí scroll hoặc visibility element trực tiếp sang tiến trình animation — không JS trên hot path. 2025–2026 đây là kiến thức nền cho UI production, không còn flag thí nghiệm Chrome.
Phạm vi: Bài này chỉ cover scroll-driven animation. Tối ưu animation CSS chung, View Transitions, và roundup CSS hiện đại có bài riêng — ta link khi cần, không lặp lại.
Mở demo đầy đủ:
Mô hình tư duy: timeline, không phải event
JS scroll truyền thống nghĩ theo event — “user cuộn 120 px, cập nhật thanh”. CSS scroll-driven nghĩ theo timeline — trục tiến trình 0→100% mà trình duyệt duy trì khi bạn cuộn.
JS scroll listener model CSS scroll-driven model
───────────────────────── ───────────────────────
scroll event → read DOM scroll offset ──► timeline progress
↓ ↓
compute % → write style timeline progress ──► keyframe %
↓ ↓
(main thread, every frame) (compositor, no JS)
Hai loại timeline cover hầu hết pattern scroll UX:
| Timeline | Shorthand | What progress means | Typical use |
|---|---|---|---|
| Scroll timeline | scroll() | How far a scroll container has scrolled (0% = top, 100% = bottom) | Progress bars, chapter indicators, parallax tied to scroll distance |
| View timeline | view() | How an element intersects its scrollport (entry → cover → exit) | Reveal-on-scroll, fade-out on leave, sticky header shrink |
Cả hai gắn vào @keyframes thông thường qua animation-timeline — bạn không học syntax animation mới, chỉ nguồn tiến trình mới.
scroll() — tiến trình từ offset cuộn
Scroll timeline theo dõi vị trí cuộn của container scroll. Element bên trong (hoặc ngoài, với named timeline) bind animation vào tiến trình đó.
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
transform-origin: left center;
animation: grow-bar linear;
animation-timeline: scroll();
}
Trụ scroll và chọn scroller
Hàm scroll() nhận đối số tuỳ chọn:
animation-timeline: scroll(); /* nearest scroll ancestor, block axis */
animation-timeline: scroll(block nearest); /* explicit axis + scroller */
animation-timeline: scroll(root); /* document viewport */
animation-timeline: scroll(self); /* element is its own scroller */
| Argument | Values | Meaning |
|---|---|---|
| Axis | block (default), inline, x, y | Which scroll direction drives progress |
| Scroller | nearest (default), root, self | Which element’s scroll offset is tracked |
Với panel scroll lồng nhau, scroll(nearest block) bind ancestor overflow: auto gần nhất — đúng như demo.
Thực tế: thanh tiến độ đọc toàn trang
.progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--accent);
transform-origin: left;
z-index: 9999;
animation: grow-bar linear;
animation-timeline: scroll(root block);
}
scroll(root) gắn tiến trình vào scroller document — pattern mọi blog theme viết lại bằng JS một thập kỷ.
Mẹo principal engineer: Giữ animation progress bar chỉ
transformvàopacity. Tránh animatewidth— kích layout mỗi frame dù trên compositor.
view() — tiến trình từ visibility
View timeline theo dõi element subject di chuyển qua scrollport (vùng nhìn thấy của scroll container). Thay pattern reveal IntersectionObserver + toggle class.
@keyframes reveal {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
Mỗi .section có view timeline ẩn danh riêng — không ID, không registry JS, không Set “đã animate”.
Trụ view() và inset
animation-timeline: view(block); /* vertical scrollport (default) */
animation-timeline: view(inline); /* horizontal carousel */
animation-timeline: view(block 10% 20%); /* inset shrinks the scrollport edges */
Inset hữu ích khi sticky header chiếm 64 px scrollport — animation kích hoạt theo vùng nhìn thấy dưới header, không phải rìa viewport thô.
animation-range — núm điều khiển chi tiết
animation-range định nghĩa đoạn nào của timeline map sang 0%→100% keyframe. Đây là tính năng làm view timeline biểu cảm.
Từ khoá range có tên
| Phase | Meaning |
|---|---|
| entry | Element is entering the scrollport (bottom edge crosses in) |
| exit | Element is leaving the scrollport (top edge crosses out) |
| cover | Element spans the scrollport — from first fully visible to last fully visible |
| contain | Element is fully contained within the scrollport |
| entry-crossing / exit-crossing | Finer crossing events (spec-level; check support) |
/* Fade in while entering */
animation-range: entry 0% entry 100%;
/* Animate during first half of "cover" phase */
animation-range: cover 0% cover 50%;
/* Fade out as element exits upward */
animation-range: exit 0% exit 100%;
/* Only animate while fully contained */
animation-range: contain 0% contain 100%;
Kết hợp phase cho chuyển động bất đối xứng
.hero-title {
animation: slide-up linear both;
animation-timeline: view();
/* Start animating early (before fully entered), finish at cover midpoint */
animation-range: entry-crossing 0% cover 40%;
}
Demo có ba card với range khác nhau — entry, cover, exit — để bạn cảm sự khác biệt trong một lần cuộn.
Named timeline — một scroller, nhiều consumer
scroll() và view() ẩn danh đủ cho 80% case. Named timeline giải 20% khó: element ngoài scroll container cần cùng tiến trình, hoặc nhiều animation chia một nguồn sự thật.
Khai báo named scroll timeline
.article-body {
overflow: auto;
max-height: 70vh;
scroll-timeline-name: --article;
scroll-timeline-axis: block;
}
/* Inside the scroller — anonymous still works */
.progress-inner {
animation: grow linear;
animation-timeline: scroll(nearest block);
}
/* Outside the scroller — must reference the name */
.sidebar-meter {
animation: fill linear;
animation-timeline: --article;
}
Khai báo named view timeline
.card {
view-timeline-name: --card;
view-timeline-axis: block;
}
.card-glow {
/* A pseudo-element or sibling animates the same visibility progress */
animation: pulse linear both;
animation-timeline: --card;
animation-range: cover 0% cover 100%;
}
timeline-scope — chia sẻ qua subtree
Mặc định named timeline scoped trong subtree khai báo. timeline-scope trên ancestor cho tên timeline visible với descendant mà không khai báo trùng.
.layout {
timeline-scope: --article, --hero;
}
.chapter-nav {
animation: highlight linear;
animation-timeline: --article;
}
Dùng named timeline khi không cần truyền phần trăm scroll qua CSS custom property bằng JS — cả cầu nối đó biến mất.
Ẩn danh vs named: ma trận quyết định
| Scenario | Recommendation |
|---|---|
| Progress bar inside scroll container | Anonymous scroll(nearest) |
| Page-level progress bar (fixed header) | scroll(root block) |
| Reveal cards as they enter viewport | Anonymous view() + animation-range: entry … |
| Sidebar meter outside nested panel | Named scroll-timeline-name + animation-timeline: --name |
| Shared glow on card + child pseudo-element | Named view-timeline-name |
| Carousel slide emphasis | view(inline) + cover range |
Performance: vì sao compositor scroll thắng JS
Scroll-driven animation không “miễn phí” — trình duyệt vẫn evaluate timeline và composite layer. Nhưng tránh chi phí hệ thống của JS scroll handler:
JS scroll handler cost per frame Scroll-driven CSS cost
──────────────────────────────── ────────────────────────
Event dispatch on main thread Timeline sample (often compositor)
Layout reads (forced sync layout) No DOM reads in your code
Style recalc + paint for N elements Keyframe interpolation on promoted layers
GC pressure from closures/arrays Zero JS allocations
Quy tắc cho chuyển động scroll-driven mượt:
- Chỉ animate property thân compositor:
transform,opacity,filter(dùng tiết kiệm),clip-path. - Tránh
width,height,top,left,margintrong keyframe gắn scroll. - Giới hạn số element view-timeline đồng thời trên trang dài — 50 section animate ổn; 500 có thể stress evaluate timeline.
- Không ghép scroll-driven với JS cũng ghi cùng property — last writer thắng không dự đoán được.
Với trang nhạy INP, bỏ scroll listener giảm tranh chấp main thread đúng lúc CrUX đo tương tác. Xem bài INP cho phần đo lường.
Accessibility: prefers-reduced-motion
Reveal scroll-driven thường là trang trí — xử lý như mọi tuỳ chọn chuyển động:
@media (prefers-reduced-motion: reduce) {
.section,
.reading-progress {
animation: none;
}
/* Ensure content is fully visible without animation */
.section {
opacity: 1;
transform: none;
}
}
Đừng giấu thông tin quan trọng sau animation không bao giờ hoàn thành với user reduced-motion. Chỉ bắt keyframe từ opacity: 0 khi trạng thái cuối là default authored khi không animation.
Với nhạy cảm tiền đình, ưu tiên translateY(12px) nhẹ hơn parallax lớn, tránh blur exit trên body text.
Progressive enhancement với @supports
Support đầu 2026 mạnh trên Chromium và Safari; Firefox đang bắt kịp. Code production nên nâng cấp, không bắt buộc:
/* Base: content fully usable, no animation */
.card {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
@supports (animation-timeline: scroll()) {
.reading-progress {
animation: grow-bar linear;
animation-timeline: scroll(root block);
}
}
Detect từng loại timeline — trình duyệt có thể support scroll() trước view() hoặc ngược lại khi rollout.
JS tuỳ chọn cho analytics hoặc banner (không để drive animation):
const supportsScrollTimeline = CSS.supports('animation-timeline: scroll()');
const supportsViewTimeline = CSS.supports('animation-timeline: view()');
Đừng dùng JS polyfill scroll-driven bằng scroll listener trên cùng element — bạn mang lại giật vừa bỏ. Nếu motion thiết yếu, layout tĩnh; nếu trang trí, bỏ qua im lặng.
Ảnh support trình duyệt (2025–2026)
| Engine | scroll() | view() | Named timelines | Notes |
|---|---|---|---|---|
| Chromium 115+ | ✅ | ✅ | ✅ | Baseline since mid-2023 |
| Safari 17+ | ✅ | ✅ | ✅ | iOS 17+ |
| Firefox 110+ | ✅ (rolling) | ✅ (rolling) | ✅ | Verify current release in your support matrix |
| Legacy / embedded WebViews | ❌ | ❌ | ❌ | @supports fallback required |
Kiểm tra caniuse trước khi bỏ hẳn JS fallback cho audience bạn.
Playbook migrate: JS scroll → CSS timeline
Trước (reveal IntersectionObserver)
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
},
{ threshold: 0.15 }
);
document.querySelectorAll('.section').forEach((el) => observer.observe(el));
.section {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.5s, transform 0.5s;
}
.section.is-visible {
opacity: 1;
transform: translateY(0);
}
Sau (view timeline)
@keyframes reveal {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.section {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@media (prefers-reduced-motion: reduce) {
.section { animation: none; opacity: 1; transform: none; }
}
Xoá observer, toggle class, và transition — ít hơn ba phần dễ vỡ.
Trước (progress đọc JS)
window.addEventListener('scroll', () => {
const scrolled = window.scrollY;
const total = document.documentElement.scrollHeight - window.innerHeight;
const pct = total > 0 ? scrolled / total : 0;
progressBar.style.transform = `scaleX(${pct})`;
}, { passive: true });
Sau (scroll timeline)
.progress-bar {
transform-origin: left;
animation: grow-bar linear;
animation-timeline: scroll(root block);
}
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Không listener. Không đọc layout. Progress bar cập nhật theo tần compositor.
Lỗi thường gặp
Lỗi 1: Animate từ opacity: 0 không fallback
Nếu @supports fail, element vô hình trừ khi bạn set default visible ngoài block.
Lỗi 2: Sai scroller với nearest
Progress bar trong modal có thể bind sai ancestor nếu nhiều scroll container lồng nhau. Dùng named timeline trên scroller đúng.
Lỗi 3: Bất ngờ animation-fill-mode
Dùng both trên view timeline để trước entry giữ keyframe from và sau exit giữ to.
Lỗi 4: overflow: hidden ancestor xung đột
Ancestor overflow: hidden có thể thành scrollport bất ngờ, lệch phase view-timeline. Inspect scrollport trong DevTools → Animations.
Khi JS vẫn thắng
CSS scroll-driven không thay thế vạn năng:
- Logic phức tạp — snap chapter rời rạc theo heading, sync video, drive canvas/WebGL.
- iframe cross-origin — timeline không qua ranh giới iframe.
- Nội dung động — list ảo mount/unmount node có thể cần JS re-bind; view timeline trên DOM ổn định thì ổn.
- Analytics — “user đọc 75% bài” vẫn cần JS hoặc event Scroll-driven Animations API (nơi support).
Dùng CSS cho chuyển động; JS cho ngữ nghĩa và đo lường.
Checklist trước khi ship
@supportsfallback — nội dung đọc được không timelineprefers-reduced-motiontắt motion scroll trang trí- Chỉ
transform/opacity(hoặc prop compositor đã vet) trong keyframe - Named timeline khi consumer ngoài scroll container
- Không JS scroll handler trùng ghi cùng property
- Test panel scroll lồng nhau (modal, drawer) — verify
nearestđúng - Verify WebView đích nếu ship hybrid app
Đọc thêm
- Demo tương tác:
- Cấp animation CSS (compositor vs main thread):
- MDN: Scroll-driven animations
- W3C: Scroll-driven Animations Module Level 1
Scroll-driven animation là tính năng CSS hiếm xoá code thay vì thêm. Bắt đầu với một progress bar và một pattern reveal trong layout tiếp theo — rồi xoá file scroll listener không còn cần.