For as long as scroll-driven animation has existed on the web, it has meant JavaScript: a scroll event listener, a getBoundingClientRect() call, and — if you wanted it smooth — a requestAnimationFrame loop tying the two together. GSAP's ScrollTrigger made that pattern pleasant to write, but it was still fundamentally the browser's main thread doing math on every scroll tick. The CSS Scroll-Driven Animations API changes the deal entirely: animation-timeline: scroll() and animation-timeline: view() let a @keyframes animation's 0%–100% playback position bind directly to scroll position instead of to a duration in seconds — computed by the browser's compositor, off the main thread, with no listener anywhere in the JavaScript.
Eight of the ten snippets below use exactly that — genuinely zero JavaScript driving the animation, verified by reading the actual js field of each one (it only logs a feature-support check, nothing more). The other two are included deliberately as an honest counterpoint: real numeric counting and per-element staggered reveals still need IntersectionObserver and requestAnimationFrame today, and knowing which category a given effect falls into is most of the skill here. Every one is a live, interactive preview you can try right here in the article, and each exports to React, Vue, Angular or Tailwind in one click from its snippet page.
What these ten get right
- The timeline doesn't care what it's driving.
animation-timeline: scroll(root)shows up in this batch animating an SVGstroke-dashoffset, afilter: hue-rotate(), atransform: scaleX(), and a CSScounter()— the same primitive, four completely different properties, because a scroll timeline is just a 0%–100% progress value with no opinion about what consumes it. - A timeline's source and its consumer don't have to be the same element. The heading-underline snippet puts
view-timeline-name: --para-readon a paragraph andanimation-timeline: --para-readon a completely different underline bar beneath it — so the underline fills based on how far the reader has scrolled through the paragraph's own text, not its own position. steps()beatslinearthe moment the value is discrete. The section counter animates a CSScounter()— a whole number that cannot interpolate fractionally — usinganimation: steps(4)rather thanlinear, so the displayed number always lands on a clean integer instead of a nonsensical blended one.view-timeline-axisfollows the scroll direction the layout actually uses. Vertical page layouts use the defaultblockaxis; the horizontally-scrolling saturation gallery and snap carousel both switch toview-timeline-axis: inlineso each frame's timeline tracks its own left-right position instead of a vertical one that would never change.- An
@supports not (...)fallback is not optional. Every native-CSS snippet in this batch wraps its animation in@supports not (animation-timeline: scroll())or theview()equivalent, resetting to a sane static state — a full ring, a flat card, a mid-hue background — because Firefox and Safari support is still landing, and a browser that ignores the property entirely would otherwise leave the animated value stuck at its starting keyframe.
1. Native CSS Scroll Progress Ring
A circular scroll-progress indicator, pinned to a corner, that fills purely from CSS — no scroll listener, no JS loop.
How it works: the ring is a standard SVG <circle> with stroke-dasharray set to its own circumference and stroke-dashoffset starting at that same value, which hides the entire stroke — the classic progress-ring trick. What's different here is what animates it: a @keyframes block drives stroke-dashoffset to 0, bound via animation-timeline: scroll(root) so its playback position is the document's scroll position, not a duration. The percentage label uses a discrete-step trick — a second scroll-bound animation swaps a pseudo-element's content across ten fixed percentage strings, since plain CSS can't smoothly interpolate an arbitrary numeric string without registering a custom @property.
Best for: docs and long-form article pages where a corner ring keeps progress visible without competing with a sticky header for the same strip of space. Tip: swap scroll(root) for scroll(nearest) to track a scrollable panel instead of the whole document.
Grab the code: Native CSS Scroll Progress Ring
2. 3D Card Rotate on Scroll (view-timeline)
Each card rotates in on its Y-axis and settles flat as it enters the viewport, then rotates back out as it leaves — a per-card native timeline, not a shared one.
How it works: every card declares its own view-timeline-name: --card-in and view-timeline-axis: block. Because a view-timeline is scoped to the element that declares it, every card sharing the same custom-ident name still gets a fully independent timeline instance — card two's transit through the viewport has nothing to do with card one's. Two separate animation-range values shape the effect asymmetrically: an entry range rotates the card in as it arrives, and a distinct exit range rotates it back out as it leaves, so the turn is symmetric on both sides of the viewport rather than only animating on the way in.
Best for: a scroll-paced feature story or portfolio section where each card deserves its own small moment rather than a uniform fade. Tip: swap rotateY for rotateX for a top-down tumble, or switch view-timeline-axis to inline for a horizontally scrolling row.
Grab the code: 3D Card Rotate on Scroll
3. Saturation Gallery (view-timeline, inline axis)
A horizontally scrolling photo strip where every frame desaturates and blurs toward the edges and sharpens only in the center — the focus effect follows the scroll, not the page.
How it works: most view-timeline demos — including the View Timeline Image Reveal — use the default view-timeline-axis: block, tracking an element's vertical position as the page scrolls down. This gallery sets view-timeline-axis: inline on every frame instead, so each frame's timeline tracks its own horizontal position as the track scrolls sideways — same primitive, aimed at the axis this layout actually scrolls on. The animated properties — filter and transform: scale() — are both compositor-friendly, which matters because up to seven frames can be mid-animation simultaneously during a fast scroll; animating width instead would force layout recalculation on every one of them.
Best for: a photo strip or portfolio filmstrip where you want the currently-centered image to visually pop without a click or hover. Tip: swap the gradient placeholders for real <img> elements with no structural changes needed.
Grab the code: Saturation Gallery
4. Sticky Section Counter (scroll-timeline)
The small "02 / 05" indicator pinned beside long-form content, normally built with an IntersectionObserver — reproduced here with a native CSS counter and zero JavaScript writing to the DOM.
How it works: a pseudo-element's content: counter(sec-count, decimal-leading-zero) displays a native CSS counter, and a scroll-bound animation on counter-increment steps that counter upward using animation: steps(4) rather than linear — linear would blend between counter values in a way that makes no sense for a whole number, since CSS counters don't interpolate fractionally, while steps(4) forces four clean discrete jumps. A slim vertical track beside the number fills from 0% to 100% height using the exact same animation-timeline: scroll(root) binding, giving a continuous progress cue alongside the discrete counter.
Best for: editorial sites and documentation pages with a fixed, known number of sections. Tip: update counter-reset and the number of increment stops in the keyframes to match your real section count.
Grab the code: Sticky Section Counter
5. Hue-Rotating Scroll Background
The gradient behind the entire page slowly rotates through the color wheel as you scroll — one fixed pseudo-element, one CSS filter, one scroll timeline.
How it works: a fixed, negative-z-index pseudo-element carries a gradient background with filter: hue-rotate(0deg), animated by a @keyframes block to hue-rotate(280deg) and bound to animation-timeline: scroll(root). Because the element is position: fixed, it never itself scrolls — only the filter value driving its appearance changes, computed entirely on the compositor. It's the same primitive as the progress ring and the section counter, aimed at a completely different CSS property, which is really the point of this whole batch: the timeline is agnostic about what it drives.
Best for: a long-scroll landing page or story where a slowly shifting mood color reinforces progress without a single explicit progress indicator. Tip: keep saturate() and brightness() fixed and only animate hue-rotate() so text contrast stays legible throughout the whole scroll range.
Grab the code: Hue-Rotating Scroll Background
6. Heading Underline Fill (view-timeline)
The underline beneath each heading fills left-to-right as you scroll past that heading's own paragraph — driven by a timeline attached to the paragraph, consumed by a different element entirely.
How it works: this is the batch's clearest demonstration that a view-timeline's source and its consumer don't have to be the same element. The paragraph itself declares view-timeline-name: --para-read — it is the thing being tracked as it moves through the viewport. The underline bar sitting above it, a structurally unrelated element, declares animation-timeline: --para-read on its own ::after pseudo-element, referencing that same named timeline to drive a transform: scaleX() fill. The underline visually tracks how far the reader has scrolled through the paragraph's text specifically, not its own position on the page — a cross-element wiring that has no equivalent in a duration-based CSS animation.
Best for: long-form articles where you want a visible sense of "how much of this specific passage remains" rather than only whole-page progress. Tip: tune animation-range: cover 0% cover 85% so the underline finishes filling slightly before the reader reaches the very last line, rather than exactly at it.
Grab the code: Heading Underline Fill
7. Scroll-Snap Carousel with view-timeline Scale
Drag, swipe, or scroll the strip — native scroll-snap-type handles the snapping, and the centered slide's scale-up comes purely from a view timeline, with no carousel library and no JS position tracking.
How it works: the snapping behavior — scroll-snap-type: x mandatory on the track and scroll-snap-align: center on each slide — is entirely separate from the scaling effect, and that separation is exactly why no carousel library is needed. Each slide independently declares view-timeline-name: --slide-x with view-timeline-axis: inline, and animates transform: scale() from 0.82 up to 1 and back down using animation-range: contain 0% contain 100% — the contain range keyword, distinct from the cover range used elsewhere in this batch, ties the animation's full range to the slide being entirely contained within the scrollport rather than merely intersecting it.
Best for: a testimonial or feature carousel where the active, centered slide should visually announce itself without a JS-tracked "active index" state. Tip: the contain range keyword is what makes the scale peak exactly when a slide is fully centered — try swapping it for cover to see the more gradual, less snappy alternative.
Grab the code: Scroll-Snap Carousel
8. SVG Path Draw via view-timeline
A route line draws itself in stroke-by-stroke as you scroll, with a dot traveling along the exact same path in sync — no GSAP, no ScrollTrigger, no JavaScript driving either one.
How it works: the classic SVG line-draw technique — stroke-dasharray set to the path's total length, stroke-dashoffset starting at that same value and animating to 0 — gets its progress from animation-timeline: --route-in instead of a duration. What makes this one distinct from the progress ring's use of the same trick is that a second element, a small dot positioned via offset-path tracing the identical SVG path data, is bound to the exact same named timeline — so the stroke reveal and the dot's position along the route stay perfectly synchronized, both driven by one shared scroll-linked source of truth rather than two independently-tuned animations that could drift apart.
Best for: a travel itinerary, a delivery-route explainer, or any scrollytelling piece that benefits from a literal path being traced as the reader progresses. Tip: keep the d attribute identical between the visible path and the offset-path value — a mismatch is the most common way this effect breaks.
Grab the code: SVG Path Draw via view-timeline
9. Count-Up Stats on Scroll (IntersectionObserver)
Stat cards whose numbers count up from zero exactly once when scrolled into view — the deliberate JS counterpoint in this batch, because real numeric interpolation still needs it.
How it works: this is included specifically because it's the effect this batch's native-CSS techniques genuinely can't reach yet — plain CSS has no way to smoothly interpolate an arbitrary number into readable text without registering a custom @property, which is why the section counter above used discrete steps() instead. Here, a single shared IntersectionObserver watches every stat number via one querySelectorAll loop — far cheaper than one observer per element — and once a number enters view, requestAnimationFrame drives a 1400ms count from zero to its target, eased with 1 - Math.pow(1 - progress, 3) so it decelerates naturally into its final value instead of stopping mechanically. Before animating anything, it checks prefers-reduced-motion and, if set, jumps straight to the final number with no animation loop at all.
Best for: pricing pages, about pages, and case-study sections with a handful of headline metrics worth a moment of emphasis. Tip: this is exactly the kind of effect where JavaScript is the right tool, not CSS — don't force a discrete-steps() counter to fake smooth numeric counting when a small shared observer does it properly.
Grab the code: Count-Up Stats on Scroll
10. Direction-Aware Grid Reveal (IntersectionObserver)
Left-column cards slide in from the left, right-column cards from the right, and the middle column rises from below — one shared observer, each card's own direction read from a data attribute.
How it works: the second deliberate JS counterpoint, and for a related but distinct reason from the counter above — this needs per-element branching logic, not just a value the timeline API can't compute. Each card carries a plain data-from="left"/"right"/"up" attribute, and one shared IntersectionObserver callback reads that attribute off whichever card just entered the viewport to decide which pre-set CSS transform to clear. A view-timeline could animate any one card's own entrance, but choosing a different starting transform per card based on a data attribute is exactly the kind of per-element conditional logic that stays firmly in JavaScript's territory rather than CSS's.
Best for: a services or process-step grid where a uniform fade-up would look flatter than staggered per-column motion. Tip: keep the direction distances small (this uses 28–40px) — a direction-aware reveal reads as intentional at a subtle scale and as gimmicky at a large one.
Grab the code: Direction-Aware Grid Reveal
How to drop these into your project
- Open any snippet and hit View / Edit Code to see the HTML, CSS and JS in separate tabs, then paste the HTML into your markup, the CSS into your stylesheet, and the JS before your closing
</body>tag — or use the one-click export to React, Vue, Angular or Tailwind right from the snippet page. The eight native-CSS snippets need nothing installed at all; the two IntersectionObserver ones are still dependency-free vanilla JS. - Always ship the
@supports not (...)fallback. Every native snippet here resets to a static, sane state in unsupported browsers rather than leaving a property stuck at its very first keyframe — an invisible ring, a card frozen mid-turn, or a background stuck at its starting hue are all worse than no animation at all. - Pick
scroll()vsview()based on what you're actually tracking.animation-timeline: scroll(root)(orscroll(nearest)) ties to the whole document's or container's scroll position — right for a progress ring, a background shift, a section counter.animation-timeline: view()(via a namedview-timeline) ties to one specific element's transit through the viewport — right for anything that should animate as it individually enters and leaves, like the rotating cards or the saturation gallery. - Match
view-timeline-axisto the layout's real scroll direction. Leave it at the defaultblockfor vertical page scroll; switch toinlinethe moment the element in question scrolls horizontally, as in the saturation gallery and the snap carousel — using the wrong axis produces a timeline that never actually progresses. - Don't force CSS to fake what still needs JavaScript. Smooth numeric counting and per-element conditional branching (like a data-attribute-driven reveal direction) are still squarely JavaScript's job — reach for a shared
IntersectionObserverplusrequestAnimationFramefor those rather than contorting a discrete-steps()CSS counter into doing something it fundamentally can't.
Final thought
The real shift with the Scroll-Driven Animations API isn't that it makes existing effects prettier — it's that it moves an entire category of "listen to scroll, compute a progress value, apply it to a style" code out of JavaScript and onto the compositor, for free, with smoother behavior during fast or inertial scrolling than a hand-rolled listener usually achieves. But the two IntersectionObserver snippets in this batch aren't filler — they're the honest boundary of what the CSS-only approach can do today. A progress value the browser computes for you is not the same primitive as smooth numeric interpolation or per-element conditional logic, and knowing which side of that line a given effect falls on is the actual skill here, not just knowing the new syntax exists.
Try them, retune them to your own layout, and export to your framework of choice in one click. Browse the full collection at FWD Tools UI Snippets — it's free and runs entirely in your browser.
