04 / blog / comparisonNovember 202610 min read

React scroll animations: CSS, Framer Motion or a hook?

There are four honest ways to animate something as it scrolls into view in React: a CSS class on a scroll timeline, a small useInView hook, Motion (the library that was Framer Motion), or a component library. They differ in what the animation follows, what they cost, and what they do on the server and under reduced motion. Here is each one, written correctly, and a table to choose.

  • Comparison
  • React
  • Next.js
  • Framer Motion
CSS view()
0 KB of JS
useInView
36 lines
framer-motion
34.3 KB
svg-scroll-draw/react
15.3 KB
CSS view() entry 0% → cover 40%100% → 52%, reverses on the way back
useInView threshold 0starts at 100%, then 0.6 s on a clock
whileInView amount 0.3starts at 94% (drawn 32px low), then 0.6 s on a clock
useScroll start end → start center100% → 50%, reverses on the way back
ScrollAnimate top 88% → top 53%88% → 53%, holds on the way back
Fig. 1 — where each listing in this post animates, by where the card’s top edge is on the screen. Bars follow the scroll; rings start a 0.6 s animation that runs whatever the scroll does next.
01First

Two kinds of scroll animation

Before picking a tool, pick a behaviour, because the four ways split into two kinds. A triggered animation waits for a moment, the card entering the screen, and then plays on a clock: 0.6 seconds, whether the reader keeps scrolling, stops or goes back. A scrubbed animation has no clock. Its progress is the scroll position, so it moves only while the reader scrolls, and backwards when they scroll back.

Fig. 1 puts the listings below on one axis. The useInView hook and Motion’s whileInView are triggered; CSS view(), Motion’s useScroll and svg-scroll-draw are scrubbed. Triggered is the classic “animate on scroll” and, today, needs JavaScript to notice the moment. Scrubbed can be done in CSS alone, and reads as the page responding to the reader’s hand.

02Way 1 · css only

CSS only: a class on a scroll timeline

The smallest React scroll animation is not React at all. animation-timeline: view() drives a normal CSS animation by the element’s trip through the screen, so the component only adds a class:

reveal.css14 lines
/* reveal.css, imported once in app/layout.tsx */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%;
    }
  }
}

@keyframes reveal {
  from { opacity: 0; translate: 0 2rem; }
}
Card.tsx4 lines
// Card.tsx: no 'use client'. It stays a Server Component and ships no JS.
export function Card({ children }: { children: React.ReactNode }) {
  return <div className="card reveal">{children}</div>;
}

The range entry 0% cover 40% starts the fade as the card’s top edge enters at the bottom and ends it 40% of the way through its whole crossing, which for a card a fifth of the screen tall is when its top reaches 52% (the first bar in Fig. 1). The keyframes give only from, so the card ends at its own styles.

The trade-offs:

  • Support. Chrome and Edge since 115, Safari since 26, Firefox since 159. Everywhere else @supports skips the block and the card simply shows, which is the right failure.
  • No “once”. It is scrubbed, so scrolling back up fades the card out again. CSS has no way to hold the end state once reached.
  • No events. Nothing tells your React code the card arrived. If you need a callback, this is the wrong tool.

Everything that moves is inside prefers-reduced-motion: no-preference, so readers who asked for less motion get the card as it is. How the ranges work is in CSS scroll-driven animations explained, and the CSS scroll-driven animation generator writes this block for seven effects.

03Way 2 · a hook

A useInView hook, written correctly

For a triggered reveal with no dependency, an IntersectionObserver in an effect is enough. Five things are easy to get wrong in it, and this one handles all five:

useInView.ts36 lines
import { useEffect, useRef, useState } from 'react';

type Options = { once?: boolean; rootMargin?: string; threshold?: number };

// null until the browser has measured: the server HTML and the first client
// render agree, and nothing is hidden before JavaScript has run.
export function useInView<T extends Element>({
  once = true,
  rootMargin = '0px',
  threshold = 0,
}: Options = {}) {
  const ref = useRef<T>(null);
  const [inView, setInView] = useState<boolean | null>(null);

  useEffect(() => {
    const el = ref.current;
    // No observer (very old browser): stay null, which shows the content.
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          if (once) observer.disconnect();
        } else {
          setInView((was) => once && was === true);
        }
      },
      { rootMargin, threshold },
    );
    observer.observe(el);
    // On unmount, and between StrictMode's two development mounts.
    return () => observer.disconnect();
  }, [once, rootMargin, threshold]);

  return [ref, inView] as const;
}
Reveal.tsx15 lines
'use client';
import { useInView } from './useInView';

export function Reveal({ children }: { children: React.ReactNode }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  return (
    <div
      ref={ref}
      className="fade-up"
      data-in-view={inView === null ? undefined : String(inView)}
    >
      {children}
    </div>
  );
}
fade-up.css10 lines
/* The observed box stays put; only what is inside it moves. */
@media (prefers-reduced-motion: no-preference) {
  .fade-up > * {
    transition: opacity 0.6s ease-out, translate 0.6s ease-out;
  }
  .fade-up[data-in-view='false'] > * {
    opacity: 0;
    translate: 0 2rem;
  }
}
  • Cleanup. The effect returns observer.disconnect(), so an unmounted card stops being watched, and a route change leaves nothing behind.
  • StrictMode. In development React mounts, unmounts and mounts every effect again. The first observer is disconnected before its first callback can arrive, so the second one is the only one left. A hook that keeps the observer in a module variable, or skips the cleanup, ends up with two.
  • Once. With once the observer disconnects itself on the first sighting, and never sets the card back to hidden.
  • Server rendering. The state starts as null, not false. The server HTML has no data-in-view at all, so it matches the first client render, and the CSS only hides what is inside [data-in-view='false'], which only the observer can set. If the bundle never loads, every card is visible. IntersectionObserver is only touched inside the effect, which never runs on the server.
  • The observed box never moves. An observer measures an element where it is drawn, transforms included. Put the 2rem drop on the observed element and it starts 2rem lower than its place, so it is seen later than you asked. With once off it is worse: a card leaving at the top is hidden, drops back into view, shows, rises out again, and loops. The hook watches the wrapper; the CSS moves only its children.

A card already on screen at load goes straight from null to true and never hides. With the default threshold: 0 that holds for any card with a single pixel showing. A negative bottom rootMargin, such as '0px 0px -15% 0px', makes reveals start higher up the screen, at a price: a card sitting in that bottom strip at load is shown, then hidden when the first callback arrives.

The scroll reveal generator builds a set of reveal presets with stagger and copies them as a React component, if you would rather not keep this file.

04Way 3 · motion

Motion: whileInView and useScroll

Framer Motion is now published as motion, and its React API is imported from motion/react. The old framer-motion package still ships alongside it, at the same version (14.14.0 for both at the time of writing), so existing imports keep working. Its triggered reveal is three props:

Card.tsx15 lines
'use client';
import { motion } from 'motion/react';

export function Card({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 32 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, amount: 0.3 }}
      transition={{ duration: 0.6, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}

amount: 0.3 waits until 30% of the card is in view, and once stops it reversing. Motion observes the motion.div itself, which initial draws 32px low, so the ring in Fig. 1 is at 94% of the card as drawn: on a screen 800px tall, its place is at 90% when it starts.

The scrubbed version reads the scroll as a motion value and maps it onto styles. Motion sets those styles directly, without a React render per frame:

ScrubCard.tsx20 lines
'use client';
import { useRef } from 'react';
import { motion, useReducedMotion, useScroll, useTransform } from 'motion/react';

export function ScrubCard({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start end', 'start center'],
  });
  const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);
  const y = useTransform(scrollYProgress, [0, 1], [32, 0]);
  const reduce = useReducedMotion();

  return (
    <motion.div ref={ref} style={reduce ? undefined : { opacity, y }}>
      {children}
    </motion.div>
  );
}

The offset pair reads “start the progress when the card’s start meets the screen’s end, finish it when the card’s start meets the centre”: 100% to 50% in Fig. 1. Like CSS, it reverses on the way back; holding the end state is code you write.

The cost is size. The framer-motion 13.1.0 CJS entry is 34.3 KB gzipped and the motion 13.1.0 single bundle 45.2 KB, measured 2026-08-14; a tree-shaken import of a few pieces is smaller than both, so measure your own build. It is worth that when you already use Motion for what it is best at: layout animation, gestures and exit animation with AnimatePresence. For scroll alone, the comparison is on svg-scroll-draw vs Framer Motion.

05Way 4 · components

svg-scroll-draw’s React components

svg-scroll-draw/react is a set of components over the library’s scroll engines: ScrollAnimate for any CSS property, ScrollText for split text, ScrollDraw for SVG strokes, ScrollCounter, ScrollVideo and ScrollFrames, plus one hook, useScrollDrawProgress. All of them are scrubbed, and once holds the end state, the one thing CSS cannot do:

Card.tsx15 lines
import { ScrollAnimate } from 'svg-scroll-draw/react';

// No 'use client' here: the components carry it, and every prop is plain data.
export function Card({ children }: { children: React.ReactNode }) {
  return (
    <ScrollAnimate
      className="card"
      props={{ opacity: [0, 1], transform: ['translateY(32px)', 'translateY(0px)'] }}
      trigger={{ start: 'top 88%', end: 'top 53%' }}
      once
    >
      {children}
    </ScrollAnimate>
  );
}

The card starts as its top passes 88% of the way down the screen and is in place by 53%. Leave out once, the trigger and callbacks, keep the other defaults, and animate only properties such as opacity and transform, and ScrollAnimate hands the work to animation-timeline: view() in browsers that have it, with the JavaScript engine everywhere else. Text and drawings are one component each:

Hero.tsx16 lines
import { ScrollDraw, ScrollText } from 'svg-scroll-draw/react';

export function Hero() {
  return (
    <section>
      <ScrollText as="h2" split="words" stagger={0.06}>
        Words arrive one at a time
      </ScrollText>
      <ScrollDraw>
        <svg viewBox="0 0 400 60" aria-hidden="true">
          <path d="M10 50 C 120 0, 280 0, 390 50" fill="none" stroke="currentColor" strokeWidth={3} />
        </svg>
      </ScrollDraw>
    </section>
  );
}

ScrollText splits the heading into words and keeps one plain copy for screen readers. For your own UI, the hook returns the same 0 to 1 progress as state:

Meter.tsx16 lines
'use client';
import { useRef } from 'react';
import { useScrollDrawProgress } from 'svg-scroll-draw/react';

export function Meter() {
  const ref = useRef<HTMLDivElement>(null);
  const progress = useScrollDrawProgress(ref, {
    trigger: { start: 'top 88%', end: 'top 53%' },
    once: true,
  });
  return (
    <div ref={ref} style={{ opacity: progress }}>
      {Math.round(progress * 100)}% read
    </div>
  );
}

That re-renders the component on every frame the value changes, which is fine for one element; for many, the components are cheaper because they write styles without rendering. The whole entry, every component and its engines, is 15.3 KB gzipped. The React scroll animation page has the quick start and every API, the blocks install ready-made sections with the shadcn CLI, and the text animation generator copies a ScrollText component.

06next.js

In the Next.js App Router

Every page in the App Router is a Server Component unless a file says 'use client'. Hooks and effects only run in client components, so the question for each way is which file needs the directive:

app/8 lines
app/
  layout.tsx      Server Component; imports reveal.css, wraps <Providers> if you use Motion
  page.tsx        Server Component; renders all of the below
  Card.tsx        CSS only: no directive, no JavaScript
  Reveal.tsx      'use client': calls useInView
  useInView.ts    no directive: only client components import it
  ScrubCard.tsx   'use client': Motion hooks
  providers.tsx   'use client': MotionConfig
  • CSS needs nothing: the class is in the server HTML, and the browser animates it.
  • The hook lives in a client component. The hook file itself needs no directive, because only client components import it.
  • Motion needs a client file for its hooks. For a plain motion.div inside a Server Component, import it from motion/react-client, whose elements carry the directive.
  • svg-scroll-draw components carry 'use client' already, so a Server Component renders <ScrollAnimate> directly. A function prop, such as onComplete, cannot cross from server to client, and needs a client file of your own.

Hydration is where they differ most. A hydration mismatch comes from rendering something on the client that the server could not know, like reading window.matchMedia in useState; all four ways here avoid that. What differs is what the server HTML shows. CSS and svg-scroll-draw send the content as it is, and the start state is applied in the browser. The hook sends no attribute, so nothing is hidden. Motion writes the initial values into the server HTML, so the card arrives at opacity: 0 and stays there until the page hydrates, or for good if the script fails. More on boundaries and route changes is on the Next.js scroll animation page.

07Accessibility

Reduced motion, way by way

A reader who sets “reduce motion” should get the content, in place, without the trip. Each way gets there differently:

  • CSS and the hook put every hiding and moving rule inside @media (prefers-reduced-motion: no-preference). Under reduced motion the rules do not exist, and the card is simply there. The media query also responds live if the setting changes.
  • Motion leaves it to you. MotionConfig with reducedMotion="user" switches off the transform animations Motion plays, so whileInView still fades but no longer slides. A useScroll scrub is not an animation Motion plays, it is your values following the scroll, so the listing above checks useReducedMotion() itself.
  • svg-scroll-draw does it by default. Every component shows its end state at once: the card in place, the text whole, the drawing drawn, the counter at its number, the video and frames on their last frame. If the reader turns the setting on while the page is open, what is animating finishes at once too. useScrollDrawProgress returns 1.
providers.tsx7 lines
'use client';
import { MotionConfig } from 'motion/react';

// Wrap the app once, in app/layout.tsx.
export function Providers({ children }: { children: React.ReactNode }) {
  return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}

What each kind of effect should do under the setting, and why a scrub that follows the reader’s own scroll can sometimes stay, is in reduced motion for scroll animations.

08Decision

Which one to use

WayJavaScriptFollowsHolds at the endReduced motionPick it when
CSS view()NoneThe scrollNoYour @media blockA fade or slide, and older browsers may just show the content
useInView hookYour 36 linesA clock, from a momentYes, onceYour @media blockOne triggered reveal and no dependency
Motion whileInView34.3 KB entryA clock, from a momentYes, viewport.onceOpt-in: MotionConfigYou already use Motion for layout, gestures or exit
Motion useScroll34.3 KB entryThe scrollCode you writeOpt-in: useReducedMotionScrubbed values in an app that has Motion
svg-scroll-draw/react15.3 KB, every componentThe scrollYes, onceOn by defaultText, SVG drawing, counters, video or frames as well as reveals
Sizes minified and gzipped: framer-motion 13.1.0 at its CJS entry, measured 2026-08-14, and the whole svg-scroll-draw/react entry. A tree-shaken Motion import is smaller.

If the effect is one fade, start with CSS, and add the hook if you need it to play once or in every browser. If you already ship Motion, use it. If the page needs more than reveals, the components do the rest with the same trigger syntax and reduced-motion handling throughout.

— / next

Copy a reveal component

Pick a preset, stagger and trigger, scroll the preview, and copy one scrollReveal call or a React component.

Star on GitHubFound this useful? A star helps others find it.

→Parts list

Keep reading

More from the blog