svg-scroll-draw is built SSR-first. All scroll APIs guard against window being undefined. Works in App Router Server Components, Client Components, and Pages Router — no hydration errors, no document is not defined.
Add 'use client' to any component that uses scroll APIs. Everything else can be a Server Component.
'use client';
import { useEffect } from 'react';
import { scrollReveal } from 'svg-scroll-draw/reveal';
import { scrollAnimate } from 'svg-scroll-draw';
export function AnimatedCards({ cards }: { cards: Card[] }) {
useEffect(() => {
const reveal = scrollReveal('.card', { preset: 'fadeUp', stagger: 0.1 });
const hero = scrollAnimate('#hero-title', {
props: { opacity: [0, 1], transform: ['translateY(32px)', 'translateY(0)'] },
easing: 'ease-out',
once: true,
});
return () => { reveal.destroy(); hero.destroy(); };
}, []);
return (
<div>
<h1 id="hero-title">Features</h1>
{cards.map(c => <div key={c.id} className="card">{c.content}</div>)}
</div>
);
}For large animated sections, lazy-load them so they don't block the initial page render.
import dynamic from 'next/dynamic';
// This section and its scroll animations only load when needed
const AnimatedShowcase = dynamic(
() => import('@/components/AnimatedShowcase'),
{
ssr: false,
loading: () => <div className="h-96 animate-pulse bg-gray-100" />,
}
);
// Server Component — no 'use client' needed here
export default async function Page() {
const data = await fetchData(); // server-side data fetching
return (
<main>
<Hero /> {/* Server Component — static */}
<AnimatedShowcase data={data} /> {/* Client, lazy */}
</main>
);
}'use client';
import { useEffect, useRef } from 'react';
import { scrollPin } from 'svg-scroll-draw/pin';
import { scrollHorizontal } from 'svg-scroll-draw/horizontal';
import { scrollReveal } from 'svg-scroll-draw/reveal';
export function ProductWalkthrough() {
const imageRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const instances = [
imageRef.current && scrollPin(imageRef.current, {
pinDistance: window.innerHeight * 3,
onEnter: () => console.log('pinned'),
}),
scrollReveal('.feature-text', { preset: 'fadeUp', stagger: 0.08 }),
].filter(Boolean);
return () => instances.forEach(i => i && i.destroy());
}, []); // run once on mount
return (
<div style={{ display: 'flex' }}>
<div ref={imageRef}>...</div>
<div className="feature-text">...</div>
</div>
);
}'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { scrollReveal } from 'svg-scroll-draw/reveal';
export function AnimatedPage({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const instanceRef = useRef<{ destroy: () => void } | null>(null);
useEffect(() => {
// Re-run on route change — pathname in deps array
instanceRef.current = scrollReveal('.animate-in', {
preset: 'fadeUp',
stagger: 0.08,
once: true,
});
return () => instanceRef.current?.destroy();
}, [pathname]); // re-initialize on route change
return <div>{children}</div>;
}✅ SSR safety
if (typeof window === 'undefined') return NOOP;. All server-side renders return a no-op instance — no hydration mismatch, no crashes.⚡ Native CSS fast path
animation-timeline: view(), svg-scroll-draw automatically uses it — zero JS scroll listeners, zero rAF calls, pure compositor animation. Falls back to JS engine seamlessly.GSAP + ScrollTrigger
Commercial license for SplitText
Framer Motion
React-only. No SVG draw, no pin/snap.
svg-scroll-draw
MIT. Everything. Works everywhere.
SSR-safe by default. App Router ready. MIT. ~9 KB.