Scroll-Driven & Parallax 3D Animations
Overview
Scroll position is the cleanest input device for narrative motion. Three layers, from cheapest to most capable:
- Native CSS —
animation-timeline: scroll() | view(). Zero JS, zero deps, ~95% modern browser support (Chrome/Edge/Opera/Samsung; Firefox flag; Safari 17.4+ partial). - Framer Motion —
useScroll+useTransform. Idiomatic in React, great for component-scoped scroll links. - GSAP ScrollTrigger — heaviest, most powerful. Required for pinning, scrubbing complex timelines, horizontal-on-vertical, snap, and cross-section choreography.
Add Lenis on top of any of the above for smooth/inertia scroll. Don't combine Lenis with scroll-snap-type (they fight).
When to Use
- Reveal-on-scroll (fade/translate as element enters viewport)
- Parallax layers (background, mid, foreground at different speeds)
- Scroll progress indicator (header bar, side rail, ring)
- Sticky storytelling (text pinned while images cycle)
- Horizontal-on-vertical scroll (panels slide sideways as page scrolls down)
- Scroll-linked 3D scene (R3F camera moves as user scrolls — pair with
react-three-fiber) - Scroll-driven SVG path drawing / morphing
Don't use when:
- Hover/click triggers a one-shot animation → plain CSS transitions or
animationsskill - You need full timeline control unrelated to scroll → Framer Motion variants directly
- You need physics-based motion →
r3f-physicsor Framer Motionspring
Setup
Native CSS — no install
Just CSS. Feature-detect:
@supports (animation-timeline: scroll()) {
/* progressive enhancement */
}
GSAP + ScrollTrigger
npm install gsap
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
Framer Motion (motion package, v11+)
npm install motion
Lenis smooth scroll
npm install lenis
Pattern: Native CSS Scroll-Linked Progress Bar
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: hotpink;
transform-origin: left;
animation: progress linear;
animation-timeline: scroll(root block);
}
scroll(root block) = the document's block-direction scroll (vertical in LTR). No JS, no rAF, browser-native — runs on the compositor.
Pattern: Native CSS View-Driven Reveal
view() timeline = the element's own visibility within the viewport.
@keyframes reveal {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
animation-range:
entry= element entering viewport (0% = first pixel visible)cover= element fully inside viewportexit= element leaving viewport
entry 0% cover 30% = animate from "first pixel visible" to "30% of element height past entry".
Pattern: GSAP ScrollTrigger — Pinned Scrub
The bread-and-butter scroll choreography:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.story',
start: 'top top',
end: '+=2000', // pin for 2000px of scroll
pin: true,
scrub: 1, // 1s lag = buttery feel
anticipatePin: 1,
},
});
tl.to('.bg', { scale: 1.4, ease: 'none' })
.to('.headline', { yPercent: -100, opacity: 0 }, 0.2)
.from('.next-headline', { yPercent: 100, opacity: 0 }, 0.4);
Key knobs:
scrub: true= 1:1 with scroll.scrub: 0.5= 0.5s catch-up.scrub: 1feels best for most narrative.pin: true= sticky during the range.anticipatePin: 1prevents flash on fast scrolls.markers: true(dev only) = visual debug.
Always clean up in React:
useEffect(() => {
const ctx = gsap.context(() => { /* timeline */ }, scopeRef);
return () => ctx.revert();
}, []);
Pattern: GSAP Horizontal-on-Vertical
gsap.to('.panels-wrapper', {
xPercent: -100 * (panels.length - 1),
ease: 'none',
scrollTrigger: {
trigger: '.panels-container',
pin: true,
scrub: 1,
end: () => `+=${document.querySelector('.panels-wrapper')!.scrollWidth}`,
},
});
Container is full viewport, wrapper is display: flex; width: max-content;, each panel is 100vw.
Pattern: Framer Motion useScroll Parallax
'use client';
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'motion/react';
export function ParallaxBlock() {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'], // section enters bottom → leaves top
});
const y = useTransform(scrollYProgress, [0, 1], ['-20%', '20%']);
const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
return (
<section ref={ref} className="relative h-screen overflow-hidden">
<motion.div style={{ y, opacity }} className="absolute inset-0 bg-cover" />
</section>
);
}
offset notation: [startThreshold, endThreshold] where each is [elementEdge] [viewportEdge].
Common offsets:
['start end', 'end start']= full pass-through (most common)['start start', 'end end']= pinned-like progress['start end', 'start start']= entry only['end start', 'end end']= exit only
Pattern: Framer Motion + R3F Camera Scroll
'use client';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useScroll } from 'motion/react';
import { useRef } from 'react';
function ScrollCamera({ progress }: { progress: MotionValue<number> }) {
const { camera } = useThree();
useFrame(() => {
camera.position.z = 5 - progress.get() * 4;
camera.position.y = progress.get() * 2;
camera.lookAt(0, 0, 0);
});
return null;
}
export function ScrollScene() {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref });
return (
<div ref={ref} className="h-[400vh]">
<div className="sticky top-0 h-screen">
<Canvas>
<ScrollCamera progress={scrollYProgress} />
{/* scene contents */}
</Canvas>
</div>
</div>
);
}
Outer wrapper provides scroll distance; inner sticky keeps the Canvas pinned. The MotionValue is read inside useFrame so it never triggers re-renders.
Pattern: Lenis Smooth Scroll
'use client';
import { useEffect } from 'react';
import Lenis from 'lenis';
export function SmoothScroll({ children }: { children: React.ReactNode }) {
useEffect(() => {
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
function raf(time: number) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
return () => lenis.destroy();
}, []);
return <>{children}</>;
}
To make ScrollTrigger play nice with Lenis:
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Don't use Lenis with scroll-snap-type or overflow: hidden on html/body.
Performance
- Prefer transform + opacity for scroll-linked properties. They composite on the GPU; layout-affecting properties (top, width, margin) force layout/paint at every scroll tick.
- Native
animation-timelineruns on the compositor — measurably cheaper than rAF-based libs. - For long pages with many
useScrollinstances, prefer one globaluseScrolland derive sections viauseTransformranges — fewer scroll listeners. - ScrollTrigger: prefer
scrub: 0.5–1overscrub: truefor smoother feel on low-end devices. - Set
will-change: transformon the animated element, then remove it after the section is no longer in view. - Avoid stacking 3+ heavy scroll libraries (GSAP + Framer + Lenis + Locomotive). Pick one orchestrator + Lenis at most.
Reduced Motion
@media (prefers-reduced-motion: reduce) {
* {
animation-timeline: none !important;
}
}
GSAP / Framer:
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { /* register scroll triggers */ }
Always provide a static fallback layout. Reduced-motion users should still be able to read everything.
Using with Next.js
All scroll libraries are client-only. Use the 'use client' directive on the component that owns the hook.
// app/components/Reveal.tsx
'use client';
import { motion, useScroll, useTransform } from 'motion/react';
For GSAP, dynamic-import ScrollTrigger to keep the initial bundle small:
'use client';
import { useEffect, useRef } from 'react';
export function ScrollScene() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let ctx: gsap.Context;
(async () => {
const { gsap } = await import('gsap');
const { ScrollTrigger } = await import('gsap/ScrollTrigger');
gsap.registerPlugin(ScrollTrigger);
ctx = gsap.context(() => {
gsap.to('.target', { /* ... */ });
}, ref);
})();
return () => ctx?.revert();
}, []);
return <section ref={ref}>…</section>;
}
Also: GSAP's free plugins are MIT, but ScrollSmoother / SplitText / MorphSVG require Club GSAP membership. Use Lenis + manual splitting as a free alternative.
Examples
Example 1: Sticky chapter storytelling
Long-form article with a sticky 60vh image on the right that changes per chapter. Use ScrollTrigger pin: true on the image column, and onToggle per chapter trigger to swap the image source.
Example 2: Hero text emerging from background
Native CSS view() on the headline: entry 0% entry 100%, animating transform: scale(0.5) translateZ(-200px) → scale(1) translateZ(0) with a parent perspective: 1500px. Combines with css-3d-transforms.
Example 3: Scroll progress dial in header
Native CSS only — a circular SVG with stroke-dasharray animated via animation-timeline: scroll(root). Zero JS.
Troubleshooting
Animations stutter / drop frames
Cause: animating layout properties or running many scroll listeners.
Fix: switch to transform + opacity. Consolidate useScroll instances. Profile with DevTools Performance panel — scroll-driven anims should sit on the Compositor row, not Main.
ScrollTrigger pins wrong section
Cause: trigger is inside a wrapper that itself scrolls or has overflow: hidden.
Fix: ScrollTrigger needs the trigger to live in the actual scrolling container. Use scroller: '.my-scroller' if you scroll a non-root element.
Lenis + ScrollTrigger feel disconnected
Cause: ScrollTrigger doesn't know about Lenis's lerp.
Fix: wire lenis.on('scroll', ScrollTrigger.update) and feed Lenis from gsap.ticker (see Lenis pattern above).
useScroll returns 0 / never updates
Cause: target element has zero height or isn't mounted at hook init.
Fix: ensure the ref is attached to a stable element with measurable height. For SSR, the first frame may show 0 — gate animations on mounted state if needed.
Native animation-timeline ignored on Safari
Cause: Safari < 17.4 doesn't support it; older versions ignore the property silently.
Fix: gate with @supports (animation-timeline: scroll()) and provide a Framer Motion / IntersectionObserver fallback for older Safari.
"ScrollTrigger update before kill" warnings in React
Cause: not cleaning up on unmount, especially in StrictMode (double-effect).
Fix: always use gsap.context() + ctx.revert() in cleanup. Never call ScrollTrigger.killAll() from inside a component — it nukes siblings.