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.
Why scroll listeners still jank in 2026 {Vì sao scroll listener vẫn giật năm 2026}
Every senior frontend engineer has shipped a scroll-linked feature {Mọi senior frontend đều từng ship tính năng gắn scroll}: a reading-progress bar, parallax hero, sticky chapter nav, or cards that fade in as you scroll {thanh tiến độ đọc, parallax hero, nav chapter sticky, hoặc card fade in khi cuộn}. The classic stack is window.addEventListener('scroll', …), IntersectionObserver, or a requestAnimationFrame loop that reads scrollTop and writes inline styles {stack kinh điển là window.addEventListener('scroll', …), IntersectionObserver, hoặc vòng requestAnimationFrame đọc scrollTop rồi ghi inline style}.
That pattern has three structural problems {Pattern đó có ba vấn đề cấu trúc}:
- Main-thread coupling {Gắn main thread} — scroll events fire on the main thread. If a long task blocks it, scroll handlers stall and animations stutter {scroll event chạy trên main thread. Long task chặn thread → handler trễ, animation giật}.
- Read/write thrashing {Đọc/ghi lẫn lộn} — measuring layout (
getBoundingClientRect,offsetTop) in a scroll handler forces synchronous layout. Doing it for dozens of elements per frame is a performance anti-pattern {đo layout trong scroll handler buộc layout đồng bộ. Làm cho hàng chục element mỗi frame là anti-pattern}. - Fragile timing {Timing dễ vỡ} — debouncing hides jank;
passive: truehelps delivery but not your handler cost. Libraries like Locomotive Scroll or GSAP ScrollTrigger add kilobytes and still run logic on the main thread {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 move scroll-linked motion to the compositor {CSS Scroll-Driven Animations đưa chuyển động gắn scroll lên compositor}. The browser maps scroll position or element visibility directly to animation progress — no JavaScript in the hot path {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}. In 2025–2026 this is baseline knowledge for production UIs, not an experimental Chrome flag {2025–2026 đây là kiến thức nền cho UI production, không còn flag thí nghiệm Chrome}.
Scope {Phạm vi}: This article covers scroll-driven animations only {Bài này chỉ cover scroll-driven animation}. General CSS animation optimization, View Transitions, and broad “modern CSS” roundups live in dedicated posts — we link them where relevant, not repeat them {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}.
Open the full demo {Mở demo đầy đủ}: /tools/css-scroll-animations-demo/.
The mental model: timelines, not events {Mô hình tư duy: timeline, không phải event}
Traditional scroll JS thinks in events — “user scrolled 120 px, update the bar” {JS scroll truyền thống nghĩ theo event — “user cuộn 120 px, cập nhật thanh”}. Scroll-driven CSS thinks in timelines — a continuous 0→100% progress axis that the browser maintains as you scroll {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)
Two timeline types cover almost every scroll UX pattern {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 |
Both attach to ordinary @keyframes via animation-timeline — you are not learning a new animation syntax, just a new progress driver {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() — progress from scroll offset {scroll() — tiến trình từ offset cuộn}
A scroll timeline tracks the scroll position of a scroll container {Scroll timeline theo dõi vị trí cuộn của container scroll}. An element inside (or outside, with a named timeline) binds an animation to that progress {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();
}
Scroll axis and scroller selection {Trụ scroll và chọn scroller}
The scroll() function accepts optional arguments {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 |
For a nested scroll panel (modal body, chat thread, article sidebar), scroll(nearest block) binds to the closest overflow: auto ancestor — exactly what the demo uses {Với panel scroll lồng nhau, scroll(nearest block) bind ancestor overflow: auto gần nhất — đúng như demo}.
Real-world: page-level reading progress {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) ties progress to the document scroller — the pattern every blog theme reimplemented in JS for a decade {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ỷ}.
Principal-engineer tip {Mẹo principal engineer}: Keep progress-bar animations to
transformandopacityonly {Giữ animation progress bar chỉtransformvàopacity}. Avoid animatingwidth— it triggers layout every frame even on the compositor path {Tránh animatewidth— kích layout mỗi frame dù trên compositor}.
view() — progress from visibility {view() — tiến trình từ visibility}
A view timeline tracks how a subject element moves through its scrollport (the visible region of its scroll container) {View timeline theo dõi element subject di chuyển qua scrollport (vùng nhìn thấy của scroll container)}. This replaces the IntersectionObserver + class-toggle reveal pattern {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%;
}
Each .section gets its own anonymous view timeline — no IDs, no JS registry, no “already animated” Set {Mỗi .section có view timeline ẩn danh riêng — không ID, không registry JS, không Set “đã animate”}.
view() axis and inset {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 */
The inset syntax (block-start block-end or shorthand) is useful when a sticky header eats 64 px of the scrollport — animations trigger relative to the visible area below the header, not the raw viewport edge {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 — the fine-grained control knob {animation-range — núm điều khiển chi tiết}
animation-range defines which segment of the timeline maps to your keyframes’ 0%→100% {animation-range định nghĩa đoạn nào của timeline map sang 0%→100% keyframe}. This is the feature that makes view timelines expressive {Đây là tính năng làm view timeline biểu cảm}.
Named range keywords {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%;
Combining phases for asymmetric motion {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%;
}
The demo shows three cards with different ranges — entry, cover, and exit — so you can feel the difference in one scroll session {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 timelines — one scroller, many consumers {Named timeline — một scroller, nhiều consumer}
Anonymous scroll() and view() work for 80% of cases {scroll() và view() ẩn danh đủ cho 80% case}. Named timelines solve the harder 20%: an element outside the scroll container needs the same progress, or multiple animations must share one source of truth {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}.
Declaring a named scroll timeline {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;
}
Declaring a named view timeline {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 — sharing across subtrees {timeline-scope — chia sẻ qua subtree}
By default, named timelines are scoped to the subtree where they are declared {Mặc định named timeline scoped trong subtree khai báo}. timeline-scope on an ancestor makes a timeline name visible to descendants (or siblings in some configurations) without duplicating declarations {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;
}
Use named timelines when you would otherwise pass scroll percentage through JS custom properties — that entire bridge disappears {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}.
Anonymous vs named: decision matrix {Ẩ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: why compositor scroll beats JS {Performance: vì sao compositor scroll thắng JS}
Scroll-driven animations are not “free” — the browser still evaluates timelines and composites layers {Scroll-driven animation không “miễn phí” — trình duyệt vẫn evaluate timeline và composite layer}. But they avoid the systematic costs of JS scroll handlers {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
Rules for smooth scroll-driven motion {Quy tắc cho chuyển động scroll-driven mượt}:
- Animate only compositor-friendly properties:
transform,opacity,filter(use sparingly),clip-path{Chỉ animate property thân compositor:transform,opacity,filter(dùng tiết kiệm),clip-path}. - Avoid
width,height,top,left,marginin scroll-linked keyframes {Tránhwidth,height,top,left,margintrong keyframe gắn scroll}. - Cap the number of simultaneous view-timeline elements on long pages — 50 animated sections is fine; 500 may stress timeline evaluation {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}.
- Do not pair scroll-driven animations with JS that also writes the same properties — last writer wins unpredictably {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}.
For INP-sensitive pages, removing scroll listeners directly reduces main-thread contention during the exact interactions CrUX measures {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}. See /blog/core-web-vitals-inp-performance-playbook/ for the measurement side {Xem bài INP cho phần đo lường}.
Accessibility: prefers-reduced-motion {Accessibility: prefers-reduced-motion}
Scroll-driven reveals are decorative for most content — treat them like any motion preference {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;
}
}
Do not hide critical information behind an animation that never completes for reduced-motion users {Đừng giấu thông tin quan trọng sau animation không bao giờ hoàn thành với user reduced-motion}. Start keyframes from opacity: 0 only when the final state is the authored default without animation {Chỉ bắt keyframe từ opacity: 0 khi trạng thái cuối là default authored khi không animation}.
For vestibular sensitivity, prefer subtle translateY(12px) over large parallax shifts, and avoid animation-range: exit blur on body text {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 with @supports {Progressive enhancement với @supports}
Browser support in early 2026 is strong in Chromium and Safari; Firefox has been catching up {Support đầu 2026 mạnh trên Chromium và Safari; Firefox đang bắt kịp}. Production code should enhance, not require {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);
}
}
Feature-detect each timeline type separately — a browser may support scroll() before view() or vice versa during rollout windows {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}.
Optional JS guard for analytics or a user-facing banner (not for driving animation) {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()');
Never use JS to polyfill scroll-driven timelines with scroll listeners on the same elements — you reintroduce the jank you removed {Đừ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ỏ}. If motion is essential, provide a static layout; if decorative, skip silently {Nếu motion thiết yếu, layout tĩnh; nếu trang trí, bỏ qua im lặng}.
Browser support snapshot (2025–2026) {Ả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 |
Check caniuse.com/css-scroll-driven-animations before dropping JS fallbacks entirely for your audience {Kiểm tra caniuse trước khi bỏ hẳn JS fallback cho audience bạn}.
Migration playbook: JS scroll → CSS timelines {Playbook migrate: JS scroll → CSS timeline}
Before (IntersectionObserver reveal) {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);
}
After (view timeline) {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; }
}
Delete the observer, the class toggle, and the transition — three fewer moving parts {Xoá observer, toggle class, và transition — ít hơn ba phần dễ vỡ}.
Before (JS reading progress) {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 });
After (scroll timeline) {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); }
}
Zero listeners. Zero layout reads. Progress bar updates at compositor refresh rate {Không listener. Không đọc layout. Progress bar cập nhật theo tần compositor}.
Common pitfalls {Lỗi thường gặp}
Pitfall 1: Animating from opacity: 0 without a fallback {Lỗi 1: Animate từ opacity: 0 không fallback}
If @supports fails, elements stay invisible unless you set a visible default outside the block {Nếu @supports fail, element vô hình trừ khi bạn set default visible ngoài block}.
Pitfall 2: Wrong scroller with nearest {Lỗi 2: Sai scroller với nearest}
A progress bar inside a modal may bind to the wrong ancestor if multiple scroll containers nest. Use a named timeline on the intended scroller {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}.
Pitfall 3: animation-fill-mode surprises {Lỗi 3: Bất ngờ animation-fill-mode}
Use both on view timelines so pre-entry state holds the from keyframe and post-exit holds to {Dùng both trên view timeline để trước entry giữ keyframe from và sau exit giữ to}.
Pitfall 4: Conflicting overflow: hidden on ancestors {Lỗi 4: overflow: hidden ancestor xung đột}
An ancestor with overflow: hidden may become the scrollport unexpectedly, shifting view-timeline phases. Inspect scrollport in DevTools → Animations panel {Ancestor overflow: hidden có thể thành scrollport bất ngờ, lệch phase view-timeline. Inspect scrollport trong DevTools → Animations}.
When JS still wins {Khi JS vẫn thắng}
CSS scroll-driven animations are not a universal replacement {CSS scroll-driven không thay thế vạn năng}:
- Complex logic — snap to discrete chapters based on heading positions, sync with video playback, or drive canvas/WebGL {Logic phức tạp — snap chapter rời rạc theo heading, sync video, drive canvas/WebGL}.
- Cross-origin iframes — timelines do not cross iframe boundaries {iframe cross-origin — timeline không qua ranh giới iframe}.
- Dynamic content — virtualized lists that mount/unmount nodes may need JS to re-bind; view timelines on stable DOM are fine {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 reached 75% of article” still needs JS or the Scroll-driven Animations API events (where supported) {Analytics — “user đọc 75% bài” vẫn cần JS hoặc event Scroll-driven Animations API (nơi support)}.
Use CSS for motion; use JS for semantics and measurement {Dùng CSS cho chuyển động; JS cho ngữ nghĩa và đo lường}.
Checklist before shipping {Checklist trước khi ship}
-
@supportsfallback — content readable without timelines {@supportsfallback — nội dung đọc được không timeline} -
prefers-reduced-motiondisables decorative scroll motion {prefers-reduced-motiontắt motion scroll trang trí} - Only
transform/opacity(or vetted compositor props) in keyframes {Chỉtransform/opacity(hoặc prop compositor đã vet) trong keyframe} - Named timeline when consumer is outside scroll container {Named timeline khi consumer ngoài scroll container}
- No duplicate JS scroll handler writing the same properties {Không JS scroll handler trùng ghi cùng property}
- Test nested scroll panels (modals, drawers) — verify
nearestresolves correctly {Test panel scroll lồng nhau (modal, drawer) — verifynearestđúng} - Verify on target WebViews if shipping hybrid apps {Verify WebView đích nếu ship hybrid app}
Further reading {Đọc thêm}
- Interactive demo {Demo tương tác}: /tools/css-scroll-animations-demo/
- CSS animation levels (compositor vs main thread) {Cấp animation CSS (compositor vs main thread)}: /blog/css-optimization-by-level/
- MDN: Scroll-driven animations
- W3C: Scroll-driven Animations Module Level 1
Scroll-driven animations are the rare CSS feature that deletes code instead of adding it {Scroll-driven animation là tính năng CSS hiếm xoá code thay vì thêm}. Start with one reading-progress bar and one reveal pattern in your next layout — then delete the scroll listener file you no longer need {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}.