03 / blog / framework guideNovember 202611 min read

Scroll animations in Next.js (App Router): a practical guide

In the App Router every component renders on the server first, and a scroll animation needs the browser. The work is in the seam between the two: which file says 'use client', what the server HTML shows before any script runs, and what happens when the reader navigates, streams in late content or asks for less motion. Here is each of those, with a small App Router app built and run in three browsers.

  • Framework Guide
  • Next.js
  • Server Components
  • Hydration
‘use client’
in 1 file
Server HTML
nothing hidden
svg-scroll-draw/react
15.3 KB

app/layout.tsxserver

.read-progressCSS scroll() timeline, no JavaScript

app/page.tsxserver · async, reads the data

<h1> <p>never move, never hidden

<Reveal>client island × 3

<article>one per feature, rendered on the server

<Reveal y={48}>client island

<Image>next/image, rendered on the server
Server
In the HTML. Ships no JavaScript.
Client island
In the HTML, then hydrated: Reveal and svg-scroll-draw/react, 15.3 KB gzipped, loaded once.
Fig. 1 — the example’s page by where each part runs. Every box is in the server HTML. Only the two kinds of Reveal are client islands, and what they hold was rendered on the server and handed in as children.
01The boundary

Where 'use client' goes

Every file under app/ is a Server Component until it says otherwise. The directive does not mean “this component runs in the browser” so much as “the browser starts here”: the file and everything it imports become client code, and the props that cross into it must be plain data the server can serialise.

A scroll animation reads the scroll position and the layout, so it runs in a client component. The question is how little of the page has to be one. With svg-scroll-draw the answer can be none of your files: the built svg-scroll-draw/react entry begins with "use client", so a Server Component imports ScrollAnimate or ScrollDraw and renders it directly. This site does exactly that: the Next.js scroll animation page is a Server Component rendering a live <ScrollDraw>, so the site’s build fails if the directive ever goes missing from the package.

The other entries, svg-scroll-draw, /reveal, /pin, /text, are plain functions with no directive. Call them inside useEffect in a client file, and return their destroy(); on the server each returns an instance that does nothing, so an accidental call during rendering never touches window.

What cannot cross the boundary is a function. A callback passed from a Server Component stops the build:

app/page.tsx (fails)5 lines
// app/page.tsx, a Server Component. This fails: a function cannot
// travel from the server to the browser as a prop.
<ScrollAnimate props={{ opacity: [0, 1] }} onComplete={() => console.log('seen')}>
  <Card />
</ScrollAnimate>

For a prop named on… the build stops with “Event handlers cannot be passed to Client Component props”, and for any other function, such as a counter’s format, with “Functions cannot be passed directly to Client Components”. Either way the fix is the same: the component with the callback goes in a 'use client' file of your own, and the page renders that.

02Client islands

Keep the animated part a small island

The quick fix for any client-only error is 'use client' at the top of page.tsx. It works, and it costs the page three things. A client component cannot be async, so the data fetching moves to the browser or into an effect. Everything the page imports, a Markdown parser or a date library, ships to every reader. And every child is hydrated, including the paragraphs that never move.

The better shape is a wrapper that takes children. When a Server Component puts its own JSX inside a client component, that JSX is rendered on the server and arrives as finished output: the client component places it, and none of its code ships. Fig. 1 is the example below drawn that way. The page stays a Server Component that reads its data; each card is server-rendered; only the Reveal around it is client code.

project7 lines
app/
  layout.tsx        server: a CSS progress bar, no JavaScript
  globals.css       the scroll() timeline behind it
  page.tsx          server: async, reads the data, places the islands
  about/page.tsx    server: streams a slow part in <Suspense>
components/
  Reveal.tsx        'use client': the only client file
03The example

A minimal App Router example

One client file. It sets the effect once, so every reveal on the site moves the same way:

components/Reveal.tsx19 lines
'use client';

import type { ReactNode } from 'react';
import { ScrollAnimate } from 'svg-scroll-draw/react';

type Props = { children: ReactNode; y?: number };

/** Fades its children up as they scroll in, once. */
export function Reveal({ children, y = 32 }: Props) {
  return (
    <ScrollAnimate
      props={{ opacity: [0, 1], transform: [`translateY(${y}px)`, 'translateY(0px)'] }}
      trigger={{ start: 'top 88%', end: 'top 53%' }}
      once
    >
      {children}
    </ScrollAnimate>
  );
}

Each card starts as its top edge passes 88% of the way down the screen and is in place by 53%, following the scroll in between. once holds it there when the reader scrolls back up. The library measures the card where the page lays it out, not where the 32px offset draws it, so the window does not shift with the animation.

Strictly, Reveal would work without the directive, since ScrollAnimate brings its own. It is there because this is the island you own: the day it takes a callback or calls a hook, the boundary is already in the right place. Its props, children and a number, cross it as data.

app/page.tsx45 lines
import Image from 'next/image';
import Link from 'next/link';
import { Reveal } from '@/components/Reveal';

type Feature = { id: string; title: string; body: string };

// Stands in for your database or CMS. It runs on the server only.
async function getFeatures(): Promise<Feature[]> {
  return [
    { id: 'html', title: 'In the HTML', body: 'Every word is in the server response.' },
    { id: 'island', title: 'One small island', body: 'Only the reveal ships JavaScript.' },
    { id: 'motion', title: 'Reduced motion', body: 'Shown in place, without the trip.' },
  ];
}

export default async function Page() {
  const features = await getFeatures();
  return (
    <main>
      <h1>Ship the words, animate the arrival</h1>
      <p>This heading and paragraph never move, so nothing ever hides them.</p>

      {features.map((f) => (
        <Reveal key={f.id}>
          <article className="card">
            <h2>{f.title}</h2>
            <p>{f.body}</p>
          </article>
        </Reveal>
      ))}

      <Reveal y={48}>
        <Image
          src="/team.jpg"
          alt="The team at work"
          width={1200}
          height={800}
          sizes="(max-width: 768px) 100vw, 768px"
        />
      </Reveal>

      <Link href="/about">About us</Link>
    </main>
  );
}

The page has no directive. It is async, it reads its data on the server, and it places a Reveal around each part that moves. The heading and the first paragraph are not wrapped, because the reader sees them before any scroll: they need no animation, and no JavaScript.

04Zero javascript

CSS-only scroll animations in a Server Component

Some scroll effects need no JavaScript at all, and so no client component. A scroll timeline drives a CSS animation by the scroll position itself, and a Server Component only has to render the element. Here is a reading progress bar, put in the root layout so every page has it:

app/layout.tsx14 lines
import type { ReactNode } from 'react';
import './globals.css';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        {/* Driven by the page's scroll in CSS: no JavaScript at all. */}
        <div className="read-progress" aria-hidden="true" />
        {children}
      </body>
    </html>
  );
}
app/globals.css22 lines
.read-progress {
  display: none;
}

@supports (animation-timeline: scroll()) {
  .read-progress {
    display: block;
    position: fixed;
    inset: 0 0 auto;
    z-index: 10;
    height: 3px;
    background: currentColor;
    transform-origin: 0 50%;
    animation: read-progress linear both;
    animation-timeline: scroll(root);
  }
}

@keyframes read-progress {
  from { scale: 0 1; }
  to   { scale: 1 1; }
}

scroll(root) runs from the top of the page to the bottom, and the bar scales across the screen with it. Chrome and Edge have scroll timelines since 115, Safari since 26 and Firefox since 159; anywhere else the @supports block is skipped and the bar is never shown, which beats a bar stuck at zero. It stays for readers who ask for reduced motion, because it moves only while they scroll, in step with their hand. The whole technique, and a version for one article rather than the page, is in a reading progress bar in CSS.

The same works for reveals, with animation-timeline: view() on a class: the CSS way in the React post is a Server Component with a class name and no directive. What CSS cannot do is hold the end state once reached, or tell your code it happened. That is when the client island earns its bytes.

05Hydration

Keep the content in the server HTML

Between the server HTML arriving and the page hydrating there is a gap: a fraction of a second on a fast laptop, seconds on a slow phone, forever if the script fails. Whatever the server HTML shows is what the reader has in that gap. The common way to get it wrong is to put the start of the animation in the markup:

avoid.tsx5 lines
// Avoid: the server HTML says opacity 0. The card is blank until the
// script loads and hydrates, and blank for good if it never does.
<div className="opacity-0 translate-y-8 transition-all" data-reveal>
  <Card />
</div>

The same happens with a component that writes its start state on the server. Motion’s initial is one: the server HTML carries opacity: 0, as the React post’s Next.js section shows.

svg-scroll-draw’s components render your markup and nothing else: <ScrollAnimate> is a plain div with your children, no style attribute. The start state is written in the browser, after hydration, and only for an element that is still before its window. An element already past it, such as a card on screen at load, gets its end state on the first frame instead, so it never blinks. The app behind this post was checked for exactly that: its server HTML has no opacity in it, every card shows with JavaScript turned off, and the card on screen at load stayed at full opacity through hydration in all three browsers.

Two edges remain, and both are smaller than a blank page. A card already part-way through its window at load starts part-way, so keep the window’s start near the bottom of the screen (88% here) and few cards will be. And a reader who scrolls before the page hydrates sees a card in place, then sees it drop back and come in.

The other way to break hydration is to render from something only the browser knows:

avoid.tsx12 lines
'use client';
import { useState, type ReactNode } from 'react';

// Avoid: the server has no window, so it renders data-motion="on". In the
// browser of a reader who asked for reduced motion, the first render says
// "off". React does not patch an attribute on hydration: it stays "on".
export function Hero({ children }: { children: ReactNode }) {
  const [reduce] = useState(
    () => typeof window !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches,
  );
  return <section data-motion={reduce ? 'off' : 'on'}>{children}</section>;
}

In development React warns that the attributes did not match, and its message says they will not be patched up; in production it says nothing. Run under reduced motion, the attribute stayed on. To read a media query in your own components, let the server and the hydrating render agree, and change after:

components/useReducedMotion.ts15 lines
import { useSyncExternalStore } from 'react';

const QUERY = '(prefers-reduced-motion: reduce)';

function subscribe(onChange: () => void) {
  const mq = matchMedia(QUERY);
  mq.addEventListener('change', onChange);
  return () => mq.removeEventListener('change', onChange);
}

// The server and the hydrating render both read false, so they agree. React
// then renders again with the real value, and again whenever it changes.
export function useReducedMotion() {
  return useSyncExternalStore(subscribe, () => matchMedia(QUERY).matches, () => false);
}

useSyncExternalStore takes a third function for the server and for hydration, here false; React then renders again with the real value, without a mismatch, and again when the reader changes the setting. svg-scroll-draw’s components need none of this, since they read the setting in the browser, after mounting.

06Navigation

Route changes, streaming and scroll restoration

Each component sets its animation up in an effect and tears it down in the effect’s cleanup: destroy() removes its scroll and resize listeners and observers, puts back the inline styles it wrote, and drops the instance from the library’s registry. When the reader follows a <Link>, the old page’s components unmount and clean up, and the new page’s set up. In the app behind this post, six client-side navigations, three there and back, left the scroll listeners and the live instances exactly where they started.

The scroll position is Next’s to manage. Following a <Link>, Next keeps the position if the new page is in view and otherwise scrolls to its top; going back returns the reader part-way down the old page. Neither needs anything from you, because the engines measure each card’s window from the page and read the scroll on every frame, so a position simply shows each card as it is at that point. Following a link from the bottom of one page, the next page’s cards below the fold still wait for the reader; going back, every card above the screen is already in place.

A layout.tsx does not remount between its pages. Components rendered in it stay set up, and one below {children}, a footer that draws itself say, moves whenever the new page is a different height. It measures again on its own, because one shared ResizeObserver watches the body and every box above each animated element. A selector-based call made in a persistent layout, such as scrollReveal('.card'), is different: it found the cards that existed when it ran, and needs to run again when the pathname changes, as pattern 4 on the Next.js page shows.

Streaming moves things too. A <Suspense> boundary sends its fallback with the shell and its content when it is ready, and everything below it moves down when that content arrives:

app/about/page.tsx35 lines
import { Suspense } from 'react';
import { connection } from 'next/server';
import Link from 'next/link';
import { Reveal } from '@/components/Reveal';

// Stands in for a slow query. connection() makes it run per request,
// so the page streams: the shell first, the team when it is ready.
async function Team() {
  await connection();
  await new Promise((resolve) => setTimeout(resolve, 800));
  const team = ['Ada', 'Grace', 'Linus', 'Margaret'];
  return (
    <ul className="team">
      {team.map((name) => <li key={name}>{name}</li>)}
    </ul>
  );
}

export default function About() {
  return (
    <main>
      <h1>About us</h1>
      <Suspense fallback={<p>Loading the team…</p>}>
        <Team />
      </Suspense>
      <Reveal>
        <p className="card">Three roles open.</p>
      </Reveal>
      <Reveal>
        <p className="card">Offices in two cities.</p>
      </Reveal>
      <Link href="/">Home</Link>
    </main>
  );
}

Whether the reveals set up before or after the team arrives depends on when the browser hydrates. Run here, Chromium and Firefox hydrated the shell first and WebKit waited for the whole response. Both cases end well. A reveal still below the fold when the team arrives measures again when the observer reports the move, so the second card starts as its own top passes 88%, wherever that now is. A reveal that was on screen when it set up, like the first card under the short fallback, counted as seen: once keeps it shown after the team pushes it down. To have neither case, give the fallback the height of what it stands for, and nothing below it moves.

07Bundle size

next/dynamic: split the code, keep the HTML

The reflex for a library that touches the browser is next/dynamic with ssr: false. For a reveal it is the wrong tool: it takes the section out of the server HTML, so the reader gets the loading fallback, or nothing, until the chunk arrives. And in a Server Component it is not allowed at all:

app/page.tsx (fails)4 lines
// app/page.tsx, a Server Component. This fails the build.
import dynamic from 'next/dynamic';

const Globe = dynamic(() => import('@/components/Globe'), { ssr: false });

The build stops with “ssr: false is not allowed with next/dynamic in Server Components”. The Next.js docs add that a Server Component dynamically importing a client component does not get automatic code splitting either. So both kinds of dynamic import go in a client file:

components/Sections.tsx23 lines
'use client';

import dynamic from 'next/dynamic';

// Its JavaScript comes in a chunk of its own, and it is still rendered on
// the server: its words are in the HTML.
const Showcase = dynamic(() => import('./Showcase'));

// Only for a part that cannot render without a browser. The server HTML
// has the loading fallback, and nothing of the part itself.
const Globe = dynamic(() => import('./Globe'), {
  ssr: false,
  loading: () => <p>Loading the globe…</p>,
});

export function Sections() {
  return (
    <>
      <Showcase />
      <Globe />
    </>
  );
}

Run, Showcase’s words were in the server HTML and Globe’s were not: the HTML had only “Loading the globe…” until the browser drew it. Use ssr: false for a part that truly cannot render on a server, like a canvas sized from window. svg-scroll-draw is not one: it renders on the server as it is.

Splitting is worth it for a heavy section, a scroll video or an image sequence with its frames. The animation code itself is small: the whole svg-scroll-draw/react entry, every component and its engines, is 15.3 KB gzipped, loaded once and shared by every island on the page.

08next/image

Revealing a next/image

The example’s last island holds a next/image. Put the reveal on a wrapper, as there, not on the image: next/image writes its own styles to the img, such as a blurred placeholder, and the wrapper’s opacity covers the image and its placeholder alike.

  • Give it a size. width and height reserve the box before the file arrives, so the page does not shift. If an image without a size above a reveal loads late and pushes it down, the reveal measures again, as with streaming, but the reader still sees the jump.
  • Let it load lazily. next/image loads lazily by default, so a revealed image far down the page starts loading as the reader nears it, and is usually ready by the time its window starts.
  • Never reveal the hero. The largest image above the fold should be on screen the moment it loads. Next.js 16 deprecated priority for preload, and its docs recommend loading="eager" or fetchPriority="high" in most cases. Leave that one out of any reveal.
09Accessibility

Reduced motion

A reader who asks for less motion should get the content in place. svg-scroll-draw’s components do it by default: under prefers-reduced-motion: reduce each one shows its end state when it mounts, and if the reader turns the setting on while the page is open, anything part-way finishes at once. Both were checked in the app above, in all three browsers.

CSS you write yourself puts the movement inside @media (prefers-reduced-motion: no-preference), except for effects that move only with the reader’s own scroll, like the progress bar. React code that needs the setting reads it through the useReducedMotion hook above, never in a useState initialiser. Which scroll effects should stop and which can stay is in reduced motion for scroll animations.

— / next

Install a ready-made island

Each block is one 'use client' file you install with the shadcn CLI and render from a Server Component. This one is a grid of feature cards that fade up once, every card in the server HTML.

npx shadcn@latest add https://svg-scroll-draw.vercel.app/r/reveal-cards.jsonBrowse the blocks →Examples

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

→Parts list

Keep reading

More from the blog