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.
Every animation so far ran on its clock. {Mọi animation tới giờ chạy theo đồng hồ của nó.} 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() và 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
scrollhandler janks; batch DOM writes insiderequestAnimationFrame. {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ọngprefers-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}
view()a chart {view()một biểu đồ}: make the Part 20 bars grow as the chart scrolls into view withanimation-range: entry. {cho các cột Phần 20 mọc khi biểu đồ cuộn vào tầm nhìn.}- 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.}
- 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ằngscroll(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ẹ.}