All guides

Scroll Animations in React: whileInView, useScroll and IntersectionObserver

By Hizmedia · · 7 min read

Scroll animations come in two kinds. Some play once when an element comes into view, like a heading rising in. Others follow the scroll position itself, like a progress bar or a section that pins while its content changes. Motion has a tool for each, and both sit on browser APIs you can also use directly.

Play when it comes into view: whileInView

whileInView animates an element to a state while it's visible. viewport.once makes it play a single time, and viewport.amount sets how much of the element must be on screen first.

reveal.tsx
"use client";

import { motion } from "motion/react";

export function Reveal({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 24 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, amount: 0.4 }}
      transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
    >
      {children}
    </motion.div>
  );
}

Waiting for a good share of the element to be visible matters most for content people should actually see play: a number that counts up should start once it's fully on screen, or the count is over before anyone looks.

Follow the scroll position: useScroll

useScroll returns scrollYProgress, a value from 0 to 1. With a target and an offset, that range covers only the part of the page you care about: ["start end", "end start"] runs from when the element's top enters the bottom of the screen until its bottom leaves the top. useTransform then maps progress to anything you can animate.

parallax.tsx
"use client";

import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";

export function Parallax({ src }: { src: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
  const y = useTransform(scrollYProgress, [0, 1], ["-10%", "10%"]);
  return (
    <div ref={ref} className="overflow-hidden rounded-2xl">
      <motion.img src={src} alt="" style={{ y }} className="scale-110" />
    </div>
  );
}

Scroll-linked values update without re-rendering React, so even heavy pages stay smooth. Pass them straight to style, as above.

Pin a section while the story moves on

For scrollytelling, make a tall section, put the content inside a position: sticky element so it stays on screen, and use the section's scroll progress to decide what to show. The section's height is what gives people room to scroll.

pinned.tsx
<section ref={ref} style={{ height: "300vh" }}>
  <div className="sticky top-0 flex h-screen items-center justify-center">
    {/* map scrollYProgress to the step to show, e.g. Math.floor(progress * steps.length) */}
  </div>
</section>

Doing it by hand: IntersectionObserver

Without Motion, IntersectionObserver tells you when an element enters or leaves the viewport, with no scroll listeners. It's also the right tool for pausing work that's off screen, such as a canvas animation.

use-in-view.ts
import { useEffect, useRef, useState } from "react";

export function useInView<T extends Element>(threshold = 0.4) {
  const ref = useRef<T>(null);
  const [inView, setInView] = useState(false);
  useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { threshold });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [threshold]);
  return { ref, inView };
}

Keep scroll animation comfortable

  • With reduced motion on, show content in its final state and drop parallax and scroll-linked movement entirely.
  • Never hijack scrolling: animate in response to the page's own scroll rather than changing how far a scroll wheel moves.
  • Reserve space for content that animates in, so nothing shifts the layout when it appears.