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, 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.
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.
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 fileA minimal App Router example
One client file. It sets the effect once, so every reveal on the site moves the same way:
'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.
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.
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:
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>
);
}.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.
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: 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:
'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:
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.
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:
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.
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, 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:
'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.
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.
widthandheightreserve 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/imageloads 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
priorityforpreload, and its docs recommendloading="eager"orfetchPriority="high"in most cases. Leave that one out of any reveal.
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.
Star on GitHubFound this useful? A star helps others find it.