CSS Scroll-Driven Animations: 3 Patterns That Replace GSAP (2026)

2026-05-17 · Nico Brandt

Open your bundle analyzer. Look at the GSAP + ScrollTrigger entry — roughly 25KB gzipped, loaded on every page, doing the work of one progress bar and a few fade-in reveals. That import has been sitting there since the last redesign and nobody’s questioned whether it still earns its keep.

CSS scroll-driven animations now ship in Chrome, Edge, and Safari (Safari 26 was the one we’d all been waiting for). That’s roughly 83% of users running these animations on the compositor thread with zero JavaScript. I’ve moved three patterns from GSAP to pure CSS on production sites. Here are the blocks you delete, the CSS that replaces them, and the two gotchas that cost me an afternoon before I caught on.

The 30-Second Mental Model (scroll() vs view())

Three pieces, that’s it. A @keyframes block. An element with an animation set on it. And an animation-timeline that tells the browser: don’t tick this by time, tick it by scroll position.

scroll() ties progress to how far down the scroller you are. Use it for whole-page indicators — a reading progress bar pinned to the top, a chapter rail, anything where the entire document is the timeline.

view() ties progress to where one specific element sits inside the viewport. Use it for entrances, reveals, parallax — anything that should fire as an individual element shows up.

animation-range then picks which slice of the timeline drives the animation. The keywords you’ll actually use are entry (element entering the viewport), cover (element fully inside), and exit (element leaving). Pair them with percentages like entry 0% cover 40% and you’ve described, in two words, what used to take an IntersectionObserver with thresholds.

That’s the whole API surface for 90% of real patterns. One line of foreshadowing before we start writing CSS: the order you declare these properties matters more than you’d guess, and the wrong order silently does nothing. We’ll come back to it.

Pattern 1: The Reading Progress Bar

The easiest GSAP block to delete. Before:

gsap.registerPlugin(ScrollTrigger);
gsap.to(".progress", {
  scaleX: 1,
  ease: "none",
  scrollTrigger: {
    trigger: "body",
    start: "top top",
    end: "bottom bottom",
    scrub: true,
  },
});

After:

.progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 3px;
  background: #4ade80;
  transform: scaleX(0);
  transform-origin: 0 50%;
  animation: progress linear;
  animation-timeline: scroll(root block);
}

@keyframes progress {
  to { transform: scaleX(1); }
}

Eight lines of CSS, no listener, no plugin. A few things worth flagging while you’re there. transform-origin: 0 50% is what makes the bar grow from the left rather than the center — easy to forget if you’re used to thinking in pixels. scaleX over animating width is non-negotiable here: scale animations stay on the compositor, width animations hit layout and repaint on every scroll tick.

scroll(root block) says “use the root scroller, along the block axis.” For a page-wide progress bar that’s what you want. scroll(nearest) would attach to the closest scrolling ancestor — useful for indicators inside scrollable containers, less useful at the top of the page.

The tally: 25KB of GSAP gone (if this was the only thing keeping it in the bundle — check with your bundle analyzer first), one fewer scroll listener competing for the main thread, and the animation now runs on the compositor at 60fps without you doing anything clever.

That was the trivial one. The patterns where GSAP felt actually load-bearing are next.

Pattern 2: Parallax-Style Reveal with view()

You know the pattern: section enters the viewport, content fades up and slides into place, maybe a background image drifts at a slower rate for depth. The JavaScript version is either a ScrollTrigger.batch() or an IntersectionObserver that toggles an .is-visible class. Either way it’s stateful and you have to remember to clean it up.

Before:

const io = new IntersectionObserver((entries) => {
  entries.forEach(e => e.isIntersecting && e.target.classList.add("in"));
}, { threshold: 0.4 });
document.querySelectorAll(".reveal").forEach(el => io.observe(el));

After:

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

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

entry 0% cover 40% reads as: start the moment the element pokes into the viewport, finish when it’s 40% covered (40% of the way into being fully visible). The element fades and translates as it enters, then locks in. No threshold tuning, no observer to disconnect on unmount.

For the parallax twist, give a background layer its own animation across the same view() timeline with a wider range:

.reveal__bg {
  animation: drift linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}
@keyframes drift {
  from { transform: translateY(-8%); }
  to   { transform: translateY(8%); }
}

Two layers, two ranges, one scroll. No requestAnimationFrame loop, no parallax library.

One thing to set explicitly: animation-fill-mode: both (or the shorthand both keyword as above). Scroll timelines treat fill differently than time-based animations — without it, your element will “snap back” after the range ends. This is the most common “why does my reveal flicker?” question on the new API, and the answer is almost always missing fill mode.

One element solved, one parallax layer added. Now what about a row of cards that should reveal in sequence?

Pattern 3: Staggered Card Entrance Without JS

The IntersectionObserver version of this is where the JS gets ugly — a loop that observes each card, a setTimeout chain to add classes with offsets, and bugs the moment your list is dynamic.

The CSS version is almost embarrassingly short:

.card {
  animation: rise linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}

@keyframes rise {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}

That’s the entire stagger. Each card has its own view() timeline tied to its own viewport position, so cards naturally enter at slightly different scroll positions and animate at slightly different times. The stagger is scroll-based, not time-based — which, if you stop and think about it, is what you actually wanted for scroll reveals all along. The pacing now matches the user’s scroll speed instead of fighting a fixed 80ms delay.

If you genuinely need explicit ordering within a row that enters all at once, the old CSS variable trick still works — set --i per card and use it in animation-delay. But for scroll reveals on a tall grid, you almost never need it.

So that’s the kit: progress bar with scroll(), reveal with view() plus a range, stagger with view() per element. Three patterns, three rip-outs.

Now the ugly parts.

The Two Gotchas That Cost Me an Afternoon

Gotcha 1: declaration order. animation-timeline must come after the animation shorthand, not before. The shorthand resets animation-timeline back to auto, so this silently does nothing:

.bad {
  animation-timeline: view();          /* gets clobbered */
  animation: fade-up linear both;
}

Move animation-timeline below animation and it works. There’s no warning in DevTools — your animation just never starts and you wonder why. Burn this into muscle memory.

Gotcha 2: Firefox. As of May 2026, Firefox still ships zero support. Interop 2026 is pushing for it and the implementation is in flight, but you can’t bank on it for this year. That’s ~5% of users who will see nothing if you ship raw.

The progressive enhancement pattern I use:

.reveal { opacity: 1; transform: none; }   /* default: visible */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      opacity: 0;
      animation: fade-up linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%;
    }
  }
}

Firefox users see the final state instantly — no flash, no broken UI. Anyone with prefers-reduced-motion: reduce gets the same treatment, which matters: scroll-driven motion is more nauseating than time-based animation, not less, because it never lets up while you scroll. Always wrap scroll animations in the reduced-motion check.

The Safari-specific footnote: animation-fill-mode with scroll timelines is the one cross-browser behavior I’ve seen genuinely diverge in practice. Always set it explicitly to both. Never rely on the default.

When to Keep GSAP (and Stop Trying to Be Clever)

Be honest about what CSS still can’t do. Keep GSAP when you need:

The reframe: don’t delete GSAP, delete the part of GSAP that was doing trivial work. If your scroll JS is a progress bar and some reveals, ship those in CSS and load GSAP only on the pages with real choreography. Your homepage doesn’t need ScrollTrigger so your case study page can have a fancy intro. That’s what code splitting is for. The same goes for page transitions — CSS view transitions handle the simple cases that used to need libraries.

The Bottom Line

That 25KB sitting in your bundle for one progress bar and a few reveals? You can delete it today behind an @supports check, with a sensible static fallback for Firefox users who’ll get the unanimated version until late 2026.

The migration order I’d run: progress bar first (lowest risk, smallest scope), then the reveal animations, then the staggered grids. Keep GSAP only for the pages that genuinely need its timeline API.

The honest read on 2026: Safari 26 made this real, Interop 2026 is going to pull Firefox along, and a year from now scroll-driven CSS will be the default approach. Shipping it now means a smaller bundle, scroll work running on the compositor instead of the main thread, and one less library to keep up to date.

Copy the three patterns above. Wrap them in @supports. Ship them behind a feature flag if you’re cautious. Then watch your main-thread time drop on the next performance audit — that’s the part that’s hard to argue with.