cd ../writing
// css · scroll effects

CSS scroll-driven animations — parallax without JavaScript.

For 15 years, "scroll effect" meant: install GSAP, hook up ScrollTrigger, write a JavaScript event handler, debounce it, run an animation loop. Tens of kilobytes of code per effect. Now CSS has animation-timeline — and the same effect is two lines of CSS, runs on the compositor thread, costs zero JS. Shipped in Chrome 115, coming to Safari 18. Here's the complete guide.

Chrome 115+ Safari in progress 0 JS bytes © use freely

01The problem JS scroll handlers always had

Traditional scroll-driven effects work by listening to window.scroll, reading scrollY, doing math, and applying transforms in a requestAnimationFrame loop. This has three problems that no library can fully solve:

  • Main-thread cost — scroll listeners run on the same thread as everything else. On a heavy page, scroll handlers compete with React renders, image decoding, ad scripts.
  • Jank inevitability — even with rAF, the handler runs after a frame is committed. On a stutter, the animation lags one frame behind the scroll position. Users feel it.
  • State drift — JS reads positions after layout. The animation reflects where things were a few milliseconds ago, not where they are now.

CSS scroll-driven animations live in the compositor — the same place opacity and transform animations run. They're computed off the main thread, frame-perfect to the scroll position, and they cost nothing in your JS budget.

02The two timelines you actually need

There are two timeline types. They cover most production needs:

  • scroll-timeline — the animation progresses from 0% to 100% as the user scrolls through a container. Used for page progress bars, reveal effects tied to total scroll, parallax.
  • view-timeline — the animation progresses from 0% to 100% as a specific element passes through the viewport. Used for fade-in-on-scroll, scrub effects on individual sections.

That's the whole conceptual model. Pick the one that matches what you're animating, and you're done.

03Recipe 1 — Reading progress bar

The classic. A bar at the top of an article that fills as you scroll. With JavaScript, this is 20 lines and a scroll listener. With CSS scroll-timeline, it's an animation that runs on the implicit scroll() timeline:

✓ pure-CSS reading progress
@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

#progress {
  position: fixed; top: 0; left: 0;
  width: 100%; height: 2px;
  background: linear-gradient(90deg, violet, pink);
  transform-origin: 0 50%;

  animation: grow auto linear;
  animation-timeline: scroll(root);
}

animation-timeline: scroll(root) binds the animation to the root scroller's progress. The keyframes go from scaleX(0) to scaleX(1) over that progress. As you scroll, the bar fills. No JavaScript. Composited. Frame-perfect.

04Recipe 2 — Reveal on scroll

Elements that fade in as they enter the viewport. The default IntersectionObserver pattern, but native:

✓ pure-CSS reveal
@keyframes fade-up {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: translateY(0); }
}

.reveal {
  animation: fade-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 60%;
}

view() binds to the element's own viewport entry/exit. animation-range: entry 0% entry 60% says: "from the moment the element first enters the viewport (0%) until it's 60% inside, run the animation." After that, it stays at its final state.

Compare to the JS version: IntersectionObserver instance, observed entry callback, classList toggle, CSS transition. Maybe 30 lines of code spread across two files. The CSS version is six lines and runs on the compositor.

05Recipe 3 — Parallax

Different layers moving at different speeds — the classic landing-page effect that pays GSAP's mortgage. Pure CSS:

✓ multi-layer parallax
@keyframes drift-slow { to { transform: translateY(-30px); } }
@keyframes drift-med  { to { transform: translateY(-80px); } }
@keyframes drift-fast { to { transform: translateY(-150px); } }

.layer-back   { animation: drift-slow auto linear; animation-timeline: scroll(); }
.layer-middle { animation: drift-med  auto linear; animation-timeline: scroll(); }
.layer-front  { animation: drift-fast auto linear; animation-timeline: scroll(); }

Three layers, three speeds, all tied to scroll progress. Background drifts up 30px over full scroll. Middle drifts 80px. Foreground drifts 150px. The visual layering creates depth. Same effect that took 50 lines of GSAP, now 12 lines of CSS.

06Recipe 4 — Scrubbing animations

The Apple/Stripe-style "rotate the iPhone as you scroll" effect. The animation isn't tied to a clock — it's tied to scroll position, going forward when scrolling down, backward when scrolling up:

✓ scroll-scrubbed rotation
.scrubber-container {
  height: 300vh;             /* 3 viewports tall, gives scroll room */
  view-timeline-name: --t;
  view-timeline-axis: block;
}

@keyframes rotate-product {
  to { transform: rotate3d(0, 1, 0, 360deg); }
}

.scrubber-container .product {
  position: sticky;
  top: 10vh;
  animation: rotate-product linear both;
  animation-timeline: --t;
  animation-range: contain 0% contain 100%;
}

The product element rotates 360° as the user scrolls through the tall container. Scroll up, it reverses. The position: sticky keeps it pinned during the rotation window. animation-range: contain means the animation progresses while the element is fully contained in the viewport.

07The animation-range vocabulary

Knowing the range keywords unlocks 80% of useful patterns:

  • cover — entire time the element is visible (from first pixel in to last pixel out)
  • contain — only while fully visible (top edge below viewport top AND bottom edge above viewport bottom)
  • entry — while the element is entering (bottom edge crosses viewport bottom until top edge crosses)
  • exit — while the element is leaving (top edge crosses viewport top until bottom edge crosses)
  • entry-crossing / exit-crossing — finer-grained variants for half-entered states

You combine these with percentages: entry 25% means "25% into the entry phase." exit 80% means "80% through exit." This vocabulary covers nearly every "do X when element is at Y position" pattern.

08Production gotchas

The animation must specify both auto duration and linear easing. Other values are silently ignored when bound to a timeline. The whole "duration" concept doesn't apply — the timeline IS the duration.

both fill-mode is almost always what you want. Without it, the animation snaps back to its initial state outside the active range. With it, the element holds its end state after the range.

Compositor-only properties only. Like all GPU-accelerated animations, scroll-driven animations only work cheaply on transform, opacity, and filter. Animating width, height, top/left still triggers layout — defeats the purpose. Use transforms.

Safari progress isn't yet GA. As of mid-2026, Safari has scroll-timeline behind a flag. Test in Chrome and Edge for now; ship with a feature query for graceful degradation.

Reduced motion respect is on you. Wrap your scroll effects in @media (prefers-reduced-motion: no-preference). Users with vestibular disorders are particularly sensitive to scroll-bound motion — be respectful.

✓ reduced-motion guard
@media (prefers-reduced-motion: no-preference) {
  .reveal {
    animation: fade-up linear both;
    animation-timeline: view();
  }
}

What changes when you use this

The JavaScript scroll-effects era was 15 years long and produced some of the most resource-hungry websites in history. Every parallax landing page paid for itself with a slower Lighthouse score, blocked main thread, and an animation budget that ate every CPU cycle that should've gone to actually rendering the page.

CSS scroll-driven animations move all of that to where it belonged: the compositor. The performance budget collapses to nothing. You get the visual effects without the cost. The trade-off the industry made for 15 years was wrong — and we now have the platform that proves it.