jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}:

  1. 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}.
  2. 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}.
  3. Fragile timing {Timing dễ vỡ} — debouncing hides jank; passive: true helps 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: true giú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}:

TimelineShorthandWhat progress meansTypical use
Scroll timelinescroll()How far a scroll container has scrolled (0% = top, 100% = bottom)Progress bars, chapter indicators, parallax tied to scroll distance
View timelineview()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 */
ArgumentValuesMeaning
Axisblock (default), inline, x, yWhich scroll direction drives progress
Scrollernearest (default), root, selfWhich 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 transform and opacity only {Giữ animation progress bar chỉ transformopacity}. Avoid animating width — it triggers layout every frame even on the compositor path {Tránh animate width — 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}

PhaseMeaning
entryElement is entering the scrollport (bottom edge crosses in)
exitElement is leaving the scrollport (top edge crosses out)
coverElement spans the scrollport — from first fully visible to last fully visible
containElement is fully contained within the scrollport
entry-crossing / exit-crossingFiner 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()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}

ScenarioRecommendation
Progress bar inside scroll containerAnonymous scroll(nearest)
Page-level progress bar (fixed header)scroll(root block)
Reveal cards as they enter viewportAnonymous view() + animation-range: entry …
Sidebar meter outside nested panelNamed scroll-timeline-name + animation-timeline: --name
Shared glow on card + child pseudo-elementNamed view-timeline-name
Carousel slide emphasisview(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}:

  1. 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}.
  2. Avoid width, height, top, left, margin in scroll-linked keyframes {Tránh width, height, top, left, margin trong keyframe gắn scroll}.
  3. 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}.
  4. 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)}

Enginescroll()view()Named timelinesNotes
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}

  • @supports fallback — content readable without timelines {@supports fallback — nội dung đọc được không timeline}
  • prefers-reduced-motion disables decorative scroll motion {prefers-reduced-motion tắ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 nearest resolves correctly {Test panel scroll lồng nhau (modal, drawer) — verify nearest đúng}
  • Verify on target WebViews if shipping hybrid apps {Verify WebView đích nếu ship hybrid app}

Further reading {Đọc thêm}

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}.