← All Guides
tutorialsreactframer-motionscroll

Scroll-Driven Animations with Framer Motion

Build cinematic scroll effects in React using Framer Motion's useScroll, useTransform, and useSpring hooks — no scroll libraries needed.

Froiden·Apr 10, 2026·7 min

Scroll-driven animations create that cinematic feel where content responds to the user's scroll position. With Framer Motion's built-in scroll hooks, you don't need additional libraries like GSAP ScrollTrigger or Locomotive Scroll.

The Core Hooks

Framer Motion gives you three hooks that work together:

useScroll

Returns a MotionValue representing scroll progress (0 to 1) for a target element or the page.

import { useScroll } from "framer-motion";
 
// Page-level scroll
const { scrollYProgress } = useScroll();
 
// Element-level scroll (when element enters/exits viewport)
const ref = useRef(null);
const { scrollYProgress } = useScroll({
  target: ref,
  offset: ["start end", "end start"],
});

The offset prop controls when tracking starts and ends:

  • "start end" — tracking begins when the element's top reaches the viewport bottom
  • "end start" — tracking ends when the element's bottom passes the viewport top

useTransform

Maps one value range to another. This is how you convert scroll progress into animation values.

import { useTransform } from "framer-motion";
 
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [0.8, 1.2]);
const y = useTransform(scrollYProgress, [0, 1], [100, -100]);

useSpring

Wraps any MotionValue with spring physics for smoother transitions.

import { useSpring } from "framer-motion";
 
const smoothProgress = useSpring(scrollYProgress, {
  stiffness: 100,
  damping: 30,
  restDelta: 0.001,
});

Pattern 1: Fade + Slide on Enter

The most common scroll animation. Content fades in and slides up as it enters the viewport.

function FadeInSection({ children }) {
  const ref = useRef(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start end", "end start"],
  });
 
  const opacity = useTransform(scrollYProgress, [0, 0.3], [0, 1]);
  const y = useTransform(scrollYProgress, [0, 0.3], [60, 0]);
 
  return (
    <motion.div ref={ref} style={{ opacity, y }}>
      {children}
    </motion.div>
  );
}

Pattern 2: Parallax Layers

Different elements scroll at different speeds, creating depth.

function ParallaxSection() {
  const ref = useRef(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start end", "end start"],
  });
 
  const bgY = useTransform(scrollYProgress, [0, 1], ["-20%", "20%"]);
  const fgY = useTransform(scrollYProgress, [0, 1], ["10%", "-10%"]);
 
  return (
    <div ref={ref} className="relative overflow-hidden">
      <motion.div style={{ y: bgY }} className="absolute inset-0">
        {/* Background layer */}
      </motion.div>
      <motion.div style={{ y: fgY }} className="relative z-10">
        {/* Foreground content */}
      </motion.div>
    </div>
  );
}

Pattern 3: Progress-Based Color Fill

Words or elements change color based on scroll position — the karaoke effect.

function ScrollHighlight({ words }) {
  const ref = useRef(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start 0.8", "end 0.2"],
  });
 
  return (
    <p ref={ref} className="flex flex-wrap gap-x-2">
      {words.map((word, i) => {
        const start = i / words.length;
        const end = (i + 1) / words.length;
        const color = useTransform(
          scrollYProgress,
          [start, end],
          ["rgba(0,0,0,0.2)", "rgba(0,0,0,1)"]
        );
        return (
          <motion.span key={i} style={{ color }}>
            {word}
          </motion.span>
        );
      })}
    </p>
  );
}

See it live: Text Highlight on Scroll

Pattern 4: Sticky Scroll with Content Swap

A container stays pinned while content inside changes based on scroll position.

function StickyScroll({ sections }) {
  const ref = useRef(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ["start start", "end end"],
  });
 
  return (
    <div ref={ref} style={{ height: `${sections.length * 100}vh` }}>
      <div className="sticky top-0 h-screen flex items-center">
        {sections.map((section, i) => {
          const start = i / sections.length;
          const end = (i + 1) / sections.length;
          const opacity = useTransform(
            scrollYProgress,
            [start, start + 0.1, end - 0.1, end],
            [0, 1, 1, 0]
          );
          return (
            <motion.div
              key={i}
              style={{ opacity }}
              className="absolute inset-0"
            >
              {section}
            </motion.div>
          );
        })}
      </div>
    </div>
  );
}

Performance Tips

  1. Use MotionValue directly — Pass values via style={{ opacity }} instead of state. Motion values bypass React re-renders.

  2. Avoid useTransform in render loops — Create transforms once at the component level, not inside .map() callbacks that re-run on every render.

  3. Use useSpring sparingly — Spring physics on scroll values add smoothness but also latency. Only apply to values where the lag feels intentional.

  4. Set layoutScroll — If your scroll container isn't the viewport, use layoutScroll on the container for accurate measurements.

  5. Prefer will-change: transform — Framer Motion adds this automatically, but verify in DevTools that animated elements are on their own compositing layer.

Browse all animation components on Froiden UI.