All posts

· 3 min read

Scroll-linked animation: CSS, ScrollTrigger, or useScroll

Three ways to make something move as the page scrolls, what each one costs, and how to pick without guessing. Includes the same animation written in all three.

You want a card to slide up as it comes into view. Not on a timer — tied to the scroll, so it moves when the reader moves and stops when they stop.

There are three ways to do that on the web right now, and they're different enough that picking the wrong one means rewriting rather than adjusting.

CSS, with no JavaScript at all

Modern CSS can drive an animation from scroll position instead of from a clock. You write ordinary @keyframes, then tell the animation to use a scroll timeline rather than time.

@supports (animation-timeline: view()) {
  .card {
    animation-timeline: view();
    animation-range: entry 0% cover 40%;
    animation-timing-function: linear;
  }
}

That's the whole thing. The @keyframes block above it is unchanged from the time-based version, which is the part people find surprising: a CSS animation is already a function of progress, so scroll-linking it changes the input, not the animation.

The linear timing function isn't laziness. The easing already lives in the keyframe stops, and a second curve here would make a constant-speed scroll look like it accelerates.

Two things to know. It runs off the main thread, so it stays smooth while your JavaScript is busy — no other option here can say that. And support is still uneven enough that you want the @supports wrapper, which degrades to the animation simply not being scroll-linked rather than to nothing happening. Check caniuse for today's picture rather than trusting a version number in a blog post.

GSAP ScrollTrigger

ScrollTrigger is what GSAP is famous for, and it's the most capable of the three.

import gsap from "gsap";
import ScrollTrigger from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

gsap.from(".card", {
  y: 240,
  opacity: 0,
  ease: "none",
  scrollTrigger: {
    trigger: ".card",
    start: "top 80%",
    end: "top 30%",
    scrub: true,
  },
});

scrub: true is the line that makes it scroll-linked rather than scroll-triggered. Without it, scrolling past the start fires the animation and it plays on its own clock.

ease: "none" is there for the same reason as linear above, and it's easy to forget. GSAP's default ease applies to the scrub, so leaving it in means the element lags behind your scroll and catches up, which reads as lag rather than as design.

What you get for the library weight: pinning, horizontal scroll containers, ranges expressed in ScrollTrigger's own "element viewport" vocabulary, and the ability to debug it all with markers. What it costs: JavaScript on the main thread, running on scroll.

Motion's useScroll

If you're already in React and already using Motion, useScroll fits the way the rest of your components work.

const ref = useRef(null);
const { scrollYProgress } = useScroll({
  target: ref,
  offset: ["start 80%", "start 30%"],
});
const y = useTransform(scrollYProgress, [0, 1], [240, 0]);
const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);

return <motion.div ref={ref} style={{ y, opacity }} />;

scrollYProgress is a MotionValue — a number between 0 and 1 that updates outside React's render cycle, which is why this doesn't re-render your component sixty times a second. useTransform maps that progress onto whatever you're animating.

The appeal is that it's the same mental model as the rest of Motion. The cost is that it's React-only, and every property you animate needs its own useTransform.

Picking

CSS timelineScrollTriggeruseScroll
JavaScript needednoneGSAP + pluginMotion + React
Runs off main threadyesnono
Pinning, horizontal, containersnoyeslimited
Works outside Reactyesyesno
Browser supportneeds a fallbackeverywhereeverywhere
DebuggingDevToolsmarkersReact DevTools

The honest summary is that ScrollTrigger is the most powerful and the CSS timeline is the cheapest, and most scroll work is simple enough that the cheapest one is the right answer. Reach for ScrollTrigger when you need pinning or a horizontal section, which is exactly when the CSS version starts fighting you.

Not having to choose up front

This is the thing that annoyed us into building verve. The decision above is usually made once, early, by whoever set the project up — and then it's expensive to revisit, because the animation is written in the syntax of whatever won.

In verve you animate on a timeline, switch the export to scroll mode, and take whichever of the three you want. The keyframes are the same; only the thing driving them changes. If you later decide the CSS version needs to become ScrollTrigger because the section got pinned, that's a different export of the same animation rather than a rewrite.

Which also means you can try all three in an afternoon and find out which one your design actually needs, rather than deciding from a table like the one above.