jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

SVG from Zero to Senior · Part 24 — Scroll-Driven SVG Storytelling

Part 24: hand the timeline to the reader. Use the Scroll-driven Animations API (scroll() and view()) to draw an SVG path as the page scrolls, with parallax layers for depth and a JS fallback. With a live scrollytelling demo.

5 MIN READ

Every animation so far ran on its clock. {Mọi animation tới giờ chạy theo đồng hồ của .} Scrollytelling flips that: the reader’s scroll position becomes the timeline. {Scrollytelling lật điều đó: vị trí cuộn của người đọc trở thành timeline.} Scroll forward, the story plays; scroll back, it rewinds. {Cuộn tới, chuyện diễn; cuộn lui, nó tua ngược.} It’s how the best explainer articles and product pages feel alive, and modern CSS makes it shockingly simple. {Đó là cách các bài giải thích và trang sản phẩm hay nhất trở nên sống động, và CSS hiện đại làm nó đơn giản đến bất ngờ.}

Scroll the panel below: the trail draws, the hiker climbs, the sun rises, and three layers parallax. {Cuộn khung dưới: đường mòn vẽ ra, người leo núi đi lên, mặt trời mọc, và ba lớp parallax.}

Open the full demo {Mở demo đầy đủ}: /tools/svg-scroll-story-demo/.

The old way vs the new way {Cách cũ và cách mới}

The classic approach is a JS scroll listener that computes a 0→1 progress and updates styles. {Cách kinh điển là một listener scroll JS tính tiến trình 0→1 rồi cập nhật style.} It works everywhere but runs JS on the main thread for every scroll event. {Nó chạy khắp nơi nhưng chạy JS trên main thread mỗi sự kiện cuộn.} The new way is the Scroll-driven Animations API — pure CSS, running off the main thread: {Cách mới là Scroll-driven Animations API — CSS thuần, chạy ngoài main thread:}

#trail {
  stroke-dasharray: var(--len);
  stroke-dashoffset: var(--len);
  animation: draw linear both;
  animation-timeline: scroll(root);   /* tie the animation to page scroll */
}
@keyframes draw { to { stroke-dashoffset: 0; } }

animation-timeline: scroll() replaces time with scroll progress. {animation-timeline: scroll() thay thời gian bằng tiến trình cuộn.} No listener, no requestAnimationFrame, no jank. {Không listener, không requestAnimationFrame, không giật.}

scroll() vs view() {scroll()view()}

Two timelines cover most needs: {Hai loại timeline bao phủ đa số nhu cầu:}

  • scroll(<scroller>) — progress across a scroll container’s whole range (scroll(root) = the page). Great for a global progress bar or a story that spans the article. {tiến trình trên toàn dải cuộn của một container. Tốt cho thanh tiến trình toàn cục hay câu chuyện trải dài bài.}
  • view() — progress as a specific element crosses the viewport. Perfect for “reveal this chart as it scrolls into view.” {tiến trình khi một phần tử cụ thể băng qua viewport. Hoàn hảo cho “lộ biểu đồ này khi nó cuộn vào tầm nhìn.”}
.reveal {
  animation: fade-up linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;   /* start as it enters, finish 40% in */
}

animation-range is the senior control — it says which part of the element’s viewport journey maps to 0→100%. {animation-range là núm điều khiển senior — nói phần nào hành trình qua viewport của phần tử ánh xạ vào 0→100%.}

Draw-on-scroll: the line trick, scrubbed {Vẽ-theo-cuộn: chiêu vẽ đường, được tua}

This is Part 6’s line-drawing, but instead of animation-duration driving stroke-dashoffset, scroll does. {Đây là vẽ đường của Phần 6, nhưng thay vì animation-duration điều khiển stroke-dashoffset, thì cuộn điều khiển.} Set the dash array to the full path length, animate the offset to 0, and bind the timeline to scroll — the path “writes itself” exactly in step with the reader. {Đặt dash array bằng độ dài path, animate offset về 0, và buộc timeline vào cuộn — đường “tự viết” đúng nhịp người đọc.}

Parallax = different translate rates {Parallax = tốc độ dịch khác nhau}

Depth is an illusion built from one fact: near things move more than far things. {Chiều sâu là ảo giác dựng từ một sự thật: vật gần dịch nhiều hơn vật xa.} Translate each layer proportionally to scroll progress, with bigger multipliers for nearer layers: {Dịch mỗi lớp tỉ lệ với tiến trình cuộn, hệ số lớn hơn cho lớp gần hơn:}

far.style.transform  = `translateX(${-20 * p}px)`;   // distant ridge — barely moves
mid.style.transform  = `translateX(${-45 * p}px)`;
near.style.transform = `translateX(${-80 * p}px)`;   // foreground — moves most

Always ship a fallback {Luôn kèm fallback}

Scroll-driven CSS is widely supported but not universal. {CSS scroll-driven được hỗ trợ rộng nhưng chưa phổ quát.} Gate the native version behind @supports (animation-timeline: scroll()) and provide the JS-listener version otherwise — the demo does exactly this, computing p = scrollY / maxScroll for the scene while letting native CSS power the top progress bar where available. {Khóa bản native sau @supports, và cung cấp bản JS-listener cho phần còn lại — demo làm đúng vậy.}

const p = window.scrollY / (document.documentElement.scrollHeight - innerHeight);
trail.style.strokeDashoffset = len * (1 - p);

The master’s warnings {Lời cảnh báo của sư phụ}

  • Don’t hijack the scrollbar. Drive visuals with scroll; never trap or fake the scroll position — it breaks accessibility and trust. {Đừng chiếm scrollbar. Điều khiển hình ảnh bằng cuộn; đừng bẫy hay giả vị trí cuộn.}
  • Throttle JS fallbacks to rAF. A heavy scroll handler janks; batch DOM writes inside requestAnimationFrame. {Tiết chế fallback JS theo rAF.}
  • Honour prefers-reduced-motion. Reduce parallax/scrub or jump straight to end states for users who opt out. {Tôn trọng prefers-reduced-motion.}
  • Keep the story readable without scroll effects. The content must make sense if the animation never runs (progressive enhancement). {Giữ câu chuyện đọc được không cần hiệu ứng cuộn.}

Practice {Thực hành}

  1. view() a chart {view() một biểu đồ}: make the Part 20 bars grow as the chart scrolls into view with animation-range: entry. {cho các cột Phần 20 mọc khi biểu đồ cuộn vào tầm nhìn.}
  2. Two-speed parallax {Parallax hai tốc độ}: add a star layer that moves opposite to the hills for extra depth. {thêm lớp sao dịch ngược đồi.}
  3. Progress ring {Vòng tiến trình}: drive a Part 12 progress ring with scroll(root) as a reading indicator. {điều khiển vòng tiến trình Phần 12 bằng scroll(root).}

What’s next {Phần tiếp theo}

You’ve handed motion to the clock, to space, to a skeleton, and to the reader. {Bạn đã trao chuyển động cho đồng hồ, không gian, bộ xương, và người đọc.} For the finale of this arc, we make motion that simply lives — ambient scenes that loop forever. {Cho màn kết nhánh này, ta tạo chuyển động đơn giản sống — những cảnh nền lặp mãi.} In Part 25 we build ambient looping & particle scenes — seamless loops, layered drifting parallax, and a lightweight requestAnimationFrame particle system, all wrapped in a performance and prefers-reduced-motion budget. {Ở Phần 25 ta dựng cảnh nền lặp & hạt — vòng lặp liền mạch, parallax trôi nhiều lớp, và một hệ hạt requestAnimationFrame nhẹ.}