A step-by-step tutorial for building a scroll progress bar, reveal-on-scroll cards, and a shrinking hero using CSS scroll-driven animations with zero JavaScript.
Last week I deleted forty lines of IntersectionObserver boilerplate from a client site and replaced it with six lines of CSS. I have been grinning about it ever since. CSS scroll-driven animations finally work everywhere I care about: Chrome has shipped them since 2023, Safari picked them up in Safari 26 last fall, and Firefox has them in current releases. That changes the math completely. Effects that used to mean scroll listeners, throttling, and janky main-thread work now compile down to two CSS properties, and because transform and opacity animations run on the compositor, they stay silky even while your JavaScript is busy doing something dumb. In this tutorial we build three classics from scratch in one HTML file: a reading progress bar, cards that fade up as they enter the viewport, and a hero that shrinks away as you scroll. Zero JavaScript. Not a single event listener.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-Driven Animations</title>
<style>
* { box-sizing: border-box; margin: 0; }
body {
font-family: system-ui, sans-serif;
background: #0f0f14;
color: #e8e8ef;
line-height: 1.6;
}
.hero {
display: grid;
place-items: center;
min-height: 100vh;
text-align: center;
}
.hero h1 { font-size: clamp(2.5rem, 6vw, 5rem); }
.cards {
max-width: 640px;
margin: 0 auto;
padding: 2rem 1rem 50vh;
display: grid;
gap: 4rem;
}
.card {
background: #1b1b24;
border: 1px solid #2c2c3a;
border-radius: 12px;
padding: 2rem;
}
</style>
</head>
<body>
<header class="hero">
<h1>Scroll story</h1>
</header>
<main class="cards">
<article class="card"><h2>One</h2><p>Cards reveal themselves as they enter the viewport.</p></article>
<article class="card"><h2>Two</h2><p>No IntersectionObserver. No scroll listeners.</p></article>
<article class="card"><h2>Three</h2><p>The compositor runs these off the main thread.</p></article>
<article class="card"><h2>Four</h2><p>Pure CSS, all the way down.</p></article>
</main>
</body>
</html>Start with a boring page on purpose: a full-height hero and a column of cards with generous gaps so there is real scrolling to drive the animations. Nothing here is exotic. The one deliberate choice is the 50vh bottom padding on .cards, which guarantees the last card can travel fully through the viewport instead of getting stuck at the bottom of the document. Everything lives in one HTML file so you can save it and open it directly in a browser. Scroll it now and confirm it is completely static. That is our baseline, and it is also exactly what older browsers will see when we add the progressive enhancement at the end.
Here is the trick that makes this whole feature click. A scroll-driven animation is just a regular keyframe animation where the clock has been replaced by scroll position. We define an ordinary @keyframes that scales a bar from 0 to 1, then swap its timeline with animation-timeline: scroll(root), which maps the document's scroll progress onto the animation: top of page is 0%, bottom is 100%.
One gotcha that bites everyone on day one: the animation shorthand resets animation-timeline back to its default. So animation-timeline must come AFTER the shorthand, exactly as written here. Flip those two lines and the bar silently animates over zero seconds instead. Save, scroll, and watch the gradient bar track your position perfectly.
This is the part that replaces IntersectionObserver, and it is delightfully small. Where scroll() tracks a scroll container, view() gives each element its own timeline based on its journey through the viewport: 0% when it first peeks in at the bottom, 100% when it fully exits at the top. Attach a fade-up keyframe to that timeline and every card animates itself, individually, with no per-element JavaScript bookkeeping.
Notice there is no stagger logic, no data-visible class toggling, no unobserve cleanup. Each card's position IS its progress. The both fill mode matters here: it holds the from state (invisible, shifted down) before the card enters, so cards below the fold start hidden without any extra rule.
Scroll the page from the last step and you will notice a problem: the fade runs across the card's ENTIRE trip through the viewport, so cards only hit full opacity right as they leave the top. Nobody wants to read a half-transparent card in the middle of the screen.
animation-range fixes this by mapping the keyframes onto a slice of the timeline. The view() timeline exposes named ranges, and entry is the useful one here: it covers just the phase where the element crosses the bottom edge into view. entry 10% entry 80% means the fade starts once the card is 10% inside and finishes at 80%, so every card is fully solid by the time it is comfortably on screen. Play with these two numbers; they are the entire feel of the effect. The other named ranges, exit, contain, and cover, unlock leave-the-viewport effects the same way.
One more effect, because this one used to be genuinely annoying to build: the hero that scales down and fades as you scroll past it. With a scroll timeline it is three lines on the .hero plus a two-line keyframe.
The interesting bit is that animation-range also accepts raw lengths, not just named ranges. animation-range: 0 90vh pins the animation to the first 90vh of document scroll, regardless of how long the page is. Scroll 45vh and the hero is exactly halfway through its shrink; scroll back up and it reverses frame-perfectly, because scroll-driven animations are fully scrubbed by position rather than fired by triggers. That reversibility is something the old class-toggling approach never gave you for free.
Two safety nets and we ship. First, browsers that do not understand animation-timeline will ignore that property but still run the animation shorthand as a normal zero-duration animation, which snaps everything to its end keyframe. For the hero, the end keyframe is invisible. That is a broken page. The @supports not (animation-timeline: scroll()) block kills all three animations in those browsers, leaving them the plain static page from step one, and hides the now-meaningless progress bar.
Second, scroll-scrubbed motion can be rough for people with vestibular disorders, so prefers-reduced-motion: reduce gets the same treatment. Both blocks are pure appends, five minutes of work, and they make the difference between a fun demo and something you can deploy to production without a second thought.
That is the whole thing. Three scroll effects that used to be a small JavaScript subsystem, now expressed as declarative CSS that the browser can optimize however it likes. The mental model to keep: animation-timeline swaps the clock that drives a normal keyframe animation for scroll position, scroll() tracks a scroller, view() tracks an element's trip through the viewport, and animation-range decides which slice of that trip maps to your keyframes. The two gotchas worth tattooing somewhere: put animation-timeline after the animation shorthand because the shorthand resets it, and always ship the @supports not escape hatch so older browsers get a normal, readable page. Go rip out an IntersectionObserver this week. It feels fantastic.