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.
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.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.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
@supportsskips 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.
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:
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;
}'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>
);
}/* 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
oncethe observer disconnects itself on the first sighting, and never sets the card back to hidden. - Server rendering. The state starts as
null, notfalse. The server HTML has nodata-in-viewat 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.IntersectionObserveris 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
2remdrop on the observed element and it starts 2rem lower than its place, so it is seen later than you asked. Withonceoff 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.
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:
'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:
'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.
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:
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:
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:
'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.
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/
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.divinside a Server Component, import it frommotion/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 asonComplete, 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.
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.
MotionConfigwithreducedMotion="user"switches off the transform animations Motion plays, sowhileInViewstill fades but no longer slides. AuseScrollscrub is not an animation Motion plays, it is your values following the scroll, so the listing above checksuseReducedMotion()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.
useScrollDrawProgressreturns 1.
'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.
Which one to use
| Way | JavaScript | Follows | Holds at the end | Reduced motion | Pick it when |
|---|---|---|---|---|---|
| CSS view() | None | The scroll | No | Your @media block | A fade or slide, and older browsers may just show the content |
| useInView hook | Your 36 lines | A clock, from a moment | Yes, once | Your @media block | One triggered reveal and no dependency |
| Motion whileInView | 34.3 KB entry | A clock, from a moment | Yes, viewport.once | Opt-in: MotionConfig | You already use Motion for layout, gestures or exit |
| Motion useScroll | 34.3 KB entry | The scroll | Code you write | Opt-in: useReducedMotion | Scrubbed values in an app that has Motion |
| svg-scroll-draw/react | 15.3 KB, every component | The scroll | Yes, once | On by default | Text, SVG drawing, counters, video or frames as well as reveals |
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.