Motion Design for the Web: The Complete Guide
Scroll, text, images and video: the complete catalog of motion techniques for the web, with implementation in Next.js and the cases where each one pays off.
- motion
- scroll

Contents
- Contents
- 1. Fundamentals
- 2. Scroll Motion Techniques
- 3. Motion Techniques for Text
- 4. Motion Techniques for Images
- 5. Motion Techniques for Video
- 6. Page Transitions and Micro-interactions
- 7. Libraries and Tools
- 8. Implementation in Next.js
- 9. Reference and Inspiration Sites
- 10. Performance and Accessibility
- 11. A Study Path
Scroll, Text, Images and Video + Implementation in Next.js Updated August 2026
Contents
- Fundamentals: the 2 kinds of scroll animation
- Scroll Motion Techniques
- Motion Techniques for Text
- Motion Techniques for Images
- Motion Techniques for Video
- Page Transitions and Micro-interactions
- Libraries and Tools (what each one does)
- Implementation in Next.js (setup + code per technique)
- Reference and Inspiration Sites
- Performance and Accessibility
- A Suggested Study Path
1. Fundamentals
Before any technique, understand the most important distinction in scroll-based motion. Close to everything you see on award-winning sites falls into one of these two categories:
1.1 Scroll-triggered (fired by scroll)
The animation fires once when the element enters the viewport and runs with a duration of its own (time). The scroll is the trigger and nothing more.
- Examples: card fade-ins, text that rises as it appears, animated counters.
- Typical tools:
whileInView(Motion),ScrollTriggerwithout scrub (GSAP),IntersectionObserver, CSSanimation-timeline: view()with a shortanimation-range.
1.2 Scroll-linked / scrubbed (tied to the scroll)
The animation's progress is the scroll's progress. Scrolling forward advances the animation, scrolling back rewinds it. No duration in seconds exists here; a scroll range exists instead.
- Examples: parallax, a reading progress bar, Apple-style "scrubbed" video, pinned sections that transform as you scroll.
- Typical tools:
ScrollTriggerwithscrub(GSAP),useScroll+useTransform(Motion), CSSanimation-timeline: scroll().
1.3 Concepts you will use all the time
| Concept | What it is |
|---|---|
| Trigger / start / end | The viewport point where the animation begins and ends (start: "top 80%", for example) |
| Scrub | Tying timeline progress to the scroll (with or without smoothed "lag", scrub: 1 for example) |
| Pin | Fixing an element on screen while a scroll range happens "underneath" it |
| Stagger | An incremental delay between elements of a group (letters, cards, lines) |
| Lerp (linear interpolation) | The base of smooth scroll: the visual position "chases" the real scroll position |
| Easing | The acceleration curve (ease-out for entrances, ease-in-out for movements) |
| Timeline | An orchestrated sequence of tweens with control over overlap and order |
| FLIP | The First-Last-Invert-Play technique for animating layout changes with performance |
| Viewport amount / threshold | How much of the element has to be visible to fire (30%, for example) |
2. Scroll Motion Techniques
2.1 Reveal on Scroll (elements entering)
The most common technique on the web. Elements enter with fade + translate (opacity: 0 + translateY(30-60px) → final state, as a rule) when they reach ~70-85% of the viewport.
- Variations: lateral slide, scale-in (0.9 → 1), blur-in (
filter: blur(8px)→ 0), a subtle rotate. - Good practice: short distances (20-60px), 0.5-0.9s duration,
ease-out, firing once (once: true) on informational content. - How to do it:
whileInViewin Motion,ScrollTriggerin GSAP, or 100% CSS withanimation-timeline: view().
2.2 Parallax
Layers move at different speeds during the scroll, creating depth. The background moves slower (or the foreground faster).
- Variations: background parallax, multi-layer parallax (foreground/midground/background), parallax of text over images, "depth parallax" combining mouse and scroll.
- The golden rule: subtle offsets (10-30% of the section height). Exaggerated parallax makes people queasy.
- How to do it:
useScroll+useTransform(Motion),ScrollTriggerwithscrub(GSAP), or pure CSS withanimation-timeline: scroll().
2.3 Pinning (fixed / sticky sections)
An element stays stuck on screen while the user "scrolls through" a virtual range. The base of almost every scrollytelling narrative.
- Uses: a hero that transforms, step-by-step storytelling, a product rotating while specs appear beside it.
- How to do it:
pin: truein ScrollTrigger (the market standard), orposition: sticky+ CSS scroll-driven animations for simple cases. - Careful: pinning creates spacers in the layout (
pinSpacing); test resizing and mobile.
2.4 Scrub Animations (a timeline controlled by the scroll)
An entire timeline (multiple elements, a choreographed sequence) advances and rewinds with the scroll. It is the heart of modern scrollytelling.
- The classic example: a pinned section where, as you scroll: the title exits → the image scales → text 2 enters → the background color changes.
- How to do it:
gsap.timeline({ scrollTrigger: { scrub: 1, pin: true } }). The numeric scrub value (scrub: 0.5~1.5) adds smoothing.
2.5 Horizontal Scroll
The user's scroll is vertical, but the content moves along the horizontal axis. Great for galleries, case studies, timelines and portfolios.
- The implementation pattern: a pinned container + translateX of the inner content proportional to the vertical scroll. The "reserved" vertical scroll distance = the horizontal width to cover.
- Variations: horizontal sections with snap per panel, horizontal galleries inside a vertical page, inverted direction (right-to-left).
- Careful: accessibility (keyboard navigation), mobile (turning it into native horizontal scroll with
overflow-x+ snap is sometimes better), and a visual signal that more content exists.
2.6 Smooth Scrolling
It replaces the browser's "dry" scroll with a scroll carrying inertia and interpolation (lerp), giving the "cinematic" feel of agency sites.
- The standard tool in 2026: Lenis (from Darkroom Engineering, ex-Studio Freight). Light, working with native scroll (it keeps
position: sticky, accessibility and SEO) and integrating with GSAP/Motion. - Alternatives: ScrollSmoother (GSAP, with per-element lag/velocity effects), Locomotive Scroll v5 (now built on top of Lenis).
- Careful: on touch, the default is to skip smoothing (mobile's native momentum is already good). And
overflow-x: hiddenon the body breaksposition: stickywith Lenis; useoverflow-x: clip.
2.7 Scroll Progress Indicator
A bar (at the top, as a rule) that fills as reading progresses through the page or an article.
- How to do it:
useScroll().scrollYProgress→scaleX(Motion, 5 lines), or pure CSS withanimation-timeline: scroll(root)(zero JS).
2.8 Scroll Snapping
The scroll "settles" onto sections or items (fullpage sections, carousels).
- How to do it: native CSS
scroll-snap-type+scroll-snap-align(preferable), or ScrollTrigger'ssnapto settle on timeline points. - Careful: aggressive fullpage snap frustrates users; use
proximityin place ofmandatorywhere you can.
2.9 Stacked / Sticky Cards
Cards go sticky and stack: the next card rises over the previous one, which scales back a little and darkens. Very common in features/services sections.
- How to do it:
position: sticky; top: Xon each card + scale/brightness driven by the scroll (MotionuseScrollwithtargeton the container, or ScrollTrigger, or CSSview()).
2.10 Zoom / Scale on Scroll
An element (image, video, giant text) scales as you scroll: a hero that "opens" from a small rectangle to fullscreen, or an image that zooms until it reveals the next section "inside" it.
- How to do it: pin + scrub animating
scaleandclip-path/border-radius.
2.11 Velocity-based Effects
Elements react to the speed of the scroll, rather than to position alone: text that skews when you scroll fast, a marquee that accelerates and reverses direction with the scroll direction.
- How to do it:
useVelocity(scrollY)in Motion,ScrollTrigger.getVelocity()in GSAP, or the Lenis callback (which exposesvelocity).
2.12 Infinite Marquee
A band of logos/text/images scrolling in an infinite loop. Often combined with scroll velocity (item 2.11).
- How to do it: duplicate the content and animate
xfrom 0 to -50% in a loop (CSS or GSAPxPercentwithwrap).
2.13 Scroll Storytelling / Scrollytelling
A combination of everything above to tell a narrative: pinned sections + scrub + synchronized text + media that changes per step. It is the format of the big journalistic specials (NYT, Pudding.cool) and of Apple's product pages.
3. Motion Techniques for Text
3.1 Split Text Reveal (per character/word/line)
The text breaks into chars, words or lines and each piece animates with a stagger. It is the animated-typography technique of award-winning sites.
- Variations:
- Per line with a mask: each line rises from inside an
overflow: hidden(a "curtain" effect). The most elegant look and the most used. - Per word: fade/slide with a stagger of ~0.05s.
- Per character: dramatic headline entrances (0.02-0.04s stagger).
- Tools: GSAP's SplitText (free since 2025, and it handles accessibility and responsive line breaking), Motion+'s
splitText, or a manual split in React (a map of words →motion.span). - Careful: splits break with fonts that have yet to load (use
document.fonts.ready) and they need anaria-labelon the parent element for screen readers (SplitText already solves that).
3.2 Text Reveal on Scroll (progressive highlight)
A long paragraph in which the words "light up" (from gray to white/black) as the scroll advances, one by one. Popularized by agency sites and AI landing pages.
- How to do it: split into words + the opacity of each word mapped to a slice of
scrollYProgress, with the section pinned as a rule.
3.3 Typewriter
Characters appear in sequence, with a blinking cursor. Good for dev/AI product heroes.
- How to do it: libraries such as Typed.js, GSAP's TextPlugin/SplitText, or your own implementation with
setInterval/RAF.
3.4 Scramble / Decode Text
Random characters "resolve" into the final text (the Matrix/hacker effect).
- How to do it: GSAP's ScrambleTextPlugin (free), or your own implementation swapping random chars each frame.
3.5 Kinetic Typography
Giant text as the main graphic element: words moving in opposite directions on scroll, titles crossing the screen, text spinning in a circle, text that distorts.
- Variations: alternating lines moving left/right with scrub; text on an SVG path (circle/curve) rotating with the scroll (
MotionPathPlugin); skew by velocity.
3.6 Variable Font Animation
Animating the axes of a variable font (font-variation-settings: weight, width, slant) by scroll, hover or mouse proximity.
- How to do it: CSS transitions/animations on
font-variation-settings, or GSAP animating the value. A sophisticated effect and a cheap one in performance terms when used in moderation.
3.7 Text Mask / Clip Reveal
Text revealed through clip-path or background-clip: text (a gradient moving inside the text, an image or video "inside" the letters).
- How to do it:
background-clip: text+ an animatedbackground-position; or video with amask/SVG<clipPath>in the shape of text.
3.8 Hover Effects on Links and Menus
An animated underline (scaleX from left → right), character swapping on hover (a vertical roll of duplicated letters), background fill, magnetism.
- How to do it: CSS for underline/fill; GSAP/Motion for the letter roll (two copies of the text inside an
overflow: hidden, translateY on hover).
3.9 Counter / Number Ticker
Numbers counting from 0 to the final value on entering the viewport (metrics, statistics).
- How to do it:
useMotionValue+animate()in Motion, or a tween of a{ value: 0 }object in GSAP withsnap: 1, fired bywhileInView/ScrollTrigger.
4. Motion Techniques for Images
4.1 Image Reveal
The image enters with a mask effect in place of a plain fade:
- Clip-path reveal:
clip-path: inset(100% 0 0 0)→inset(0)(revealing from bottom to top; any direction works). - Curtain reveal: a colored panel covers the image and slides away.
- Scale + parent overflow: an image at
scale(1.3)inside anoverflow: hiddencontainer, both animating in opposite directions (the classic "agency reveal").
4.2 Inner Image Parallax
The image moves inside a frame with overflow: hidden as you scroll (an image 120% of the container height, translateY from -10% to 10%). It brings any photo grid to life.
4.3 Ken Burns / Slow Zoom
A slow, continuous zoom (scale 1 → 1.08 over 8-20s) on hero/background images. Cheap and effective.
4.4 Zoom Parallax / Scroll Zoom Gallery
Multiple layered images scaling at different speeds inside a pinned section, creating a "diving" effect (popularized by Olivier Larose's tutorials).
4.5 Hover Distortion (WebGL)
Liquid/rippling distortion of the image on hover, transitions with displacement maps between images. The "high-end" look of creative portfolios.
- How to do it: WebGL shaders through OGL, Three.js/React Three Fiber, or ready-made libraries (curtains.js, hover-effect.js). The cost: complexity + bundle.
4.6 Image Trail / Mouse Follow Gallery
A trail of images following the cursor (each movement "stamps" an image that scales and fades). The hero effect of creative sites.
4.7 Grid/Gallery Choreography
Choreographed grid entrances: a diagonal stagger, columns rising at different speeds (column 1 goes up, column 2 goes down on scroll), animated reordering with FLIP.
- How to do it: GSAP/Motion stagger; GSAP's Flip plugin or Motion's
layoutprop for reordering.
4.8 Image Sequence on Scroll (a frame sprite)
A sequence of dozens or hundreds of frames (JPEG/WebP) drawn on a <canvas> as you scroll, simulating a scrubbed video with perfect control. It is how Apple builds the AirPods/iPhone pages.
- How to do it: preload the frames → ScrollTrigger/useScroll maps progress → the frame index →
drawImageon the canvas. (The code sits in section 8.)
4.9 Comparison / Before-After Slider
Two overlaid images with a draggable divider, or one driven by the scroll (clip-path on the top image).
5. Motion Techniques for Video
5.1 Video Scrubbing on Scroll
The scroll progress drives the video's currentTime inside a pinned section. The user plays the video by scrolling.
- The critical technical requirements: the video needs an encode with frequent keyframes (every frame in the ideal case,
ffmpeg -g 1for example), with no audio, short and optimized. Without that, the scrub stutters. - The more reliable alternative: a canvas image sequence (4.8), above all for iOS/Safari.
5.2 Autoplay on View (play/pause by visibility)
The video plays on entering the viewport and pauses on leaving (saving battery and data, and holding attention).
- How to do it:
IntersectionObserver(or Motion'suseInView) callingvideo.play()/pause(). The video needsmuted+playsInlinefor autoplay to work.
5.3 Video Reveal / Expand on Scroll
The video starts small (or with a large border-radius) and expands to fullscreen as you scroll, pinned as a rule (the pattern of the modern SaaS homepage).
5.4 Background Video with an Animated Overlay
A background video (muted, looping) with text/gradient layers animating over it, with light parallax between the layers.
5.5 Video Mask / Video in Text
A video visible inside a shape or inside typography alone (SVG <clipPath>/mask or background-clip), with the mask animating (scale/position) on scroll.
5.6 Hover Preview
A static thumbnail that becomes a video preview on hover (portfolio/streaming cards). onMouseEnter → play; onMouseLeave → pause + reset.
5.7 Video Transitions Between Sections
A pinned video that changes "chapter" (jumping to timestamps or crossfading between videos) as text steps pass over it. The base of scrollytelling with video.
6. Page Transitions and Micro-interactions
6.1 Page Transitions (between routes)
- The View Transitions API (native): the browser takes a "snapshot" of the old state and animates to the new one. With
view-transition-name, shared elements (a thumbnail → a large image, for example) morph between pages on their own. Broad support in 2026 (Chrome/Edge since 111, Safari 18+, Firefox 129+ for same-document). Next.js carries an experimental integration with React 19.2's<ViewTransition>component (section 8.9). - AnimatePresence (Motion): enter/exit transitions driven by JS, with more creative freedom (curtains, wipes, overlays with a counter or logo).
- Curtain/Overlay transition: a panel covers the screen on exit and reveals the new page (the agency-site pattern; in non-React stacks, barba.js is the reference).
6.2 Preloader / Intro Animation
An opening screen with a percentage counter, an animated logo or swapping words, ending in a reveal of the home page. Use it with restraint (it costs the user time).
6.3 Micro-interactions
- Magnetic buttons: a button "attracted" by the cursor inside a radius (a lerp of the mouse position).
- Custom cursor: the cursor replaced by a circle with lag (lerp) that reacts to hovers (scale, a "view" label, blend mode).
- Rich hover states: cards with 3D tilt (rotateX/rotateY from the mouse), a glow following the cursor, icons with micro-animations.
- UI feedback: buttons with a spring on click, animated toggles, skeleton → content with a crossfade, toasts with a spring.
7. Libraries and Tools
7.1 The essentials (the "standard stack" of 2026)
| Tool | What it does | When to use it |
|---|---|---|
| GSAP 3.13+ | The most complete JS animation platform. Timelines, advanced easing, and plugins: ScrollTrigger (everything scroll: trigger, scrub, pin, snap), ScrollSmoother, SplitText, Flip, Observer, Draggable, MotionPath, MorphSVG, DrawSVG, ScrambleText. 100% free since April 2025 (after the Webflow acquisition), the once-paid plugins included. The official useGSAP() hook through @gsap/react handles the lifecycle in React. | Complex scrollytelling, pinning, horizontal scroll, professional text animation, SVG. It is the standard of the creative industry (most award-winning sites on Awwwards). |
| Motion v12+ (ex-Framer Motion) | A declarative animation library. In React: motion/react (the animate, whileInView, whileHover props), AnimatePresence (exit/unmount animations), layout (automatic FLIP), and the useScroll, useTransform, useSpring, useVelocity, useInView hooks. A hybrid engine: it uses native WAAPI/ScrollTimeline where it can (animations off the main thread) with a JS fallback for springs and gestures. Renamed from Framer Motion to Motion (an independent project, motion.dev); the import changed from framer-motion to motion/react. | UI animation in React/Next.js: entrances, hover, layout animations, exit animations, simple to medium scroll-linked work. The most natural DX for React components. |
| Lenis 1.x | Smooth scroll with lerp on top of the native scroll (it preserves sticky, accessibility and SEO). React integration through lenis/react (<ReactLenis root>). It syncs with ScrollTrigger and Motion. Note: the old @studio-freight/react-lenis and @studio-freight/lenis packages are discontinued; use the lenis package. | Any site that wants the premium agency "feel". It pairs with GSAP or Motion. |
| CSS Scroll-Driven Animations (native) | animation-timeline: scroll() (container progress) and view() (element visibility) + animation-range. It runs on the compositor thread: unbeatable performance, zero JS. Support in 2026: Chrome/Edge 115+, Firefox 132+, Safari 18+/26 (~85-90% global). Use @supports (animation-timeline: scroll()) for progressive enhancement. | Reveals, simple parallax, progress bars. It covers no complex pin, no timeline snap and no advanced choreography (GSAP takes over there). |
View Transitions API (native) + React <ViewTransition> | Transitions between DOM states and between pages, with shared-element morphing through view-transition-name. An experimental integration in Next.js (experimental.viewTransition). | Route and state transitions (lists, filters, thumbnail→detail) with no library. |
7.2 Complementary and specialized
| Tool | What it does |
|---|---|
| React Three Fiber + drei | Declarative Three.js in React. 3D scenes, shaders, WebGL. drei brings helpers (ScrollControls, cameras, materials). For 3D heroes and immersive experiences. |
| OGL / curtains.js | Minimalist WebGL for image effects (hover distortion, displacement) without the weight of Three.js. |
| anime.js v4 | A light, elegant JS library for property animation, SVG and timelines. A smaller alternative to GSAP for simple and medium cases. |
| React Spring | Spring-physics animation in React. An alternative to Motion with an "everything is a spring" philosophy. |
| Lottie (lottie-react) / Rive | Playback of vector animations created in After Effects (Lottie) or in the Rive editor (interactive, with state machines, and lighter). Animated icons, illustrations and mascots. |
| Swiper / Embla Carousel | Carousels and sliders with gestures. Embla is headless and light, and great with React. |
| AOS (Animate On Scroll) | Reveals through data-aos attributes. Simple, though in 2026 CSS scroll-driven animations or Motion tend to replace it. |
| Theatre.js | A visual timeline editor for complex choreography (3D included) with a keyframe interface. |
| barba.js | Page transitions on non-React multi-page sites. |
| Locomotive Scroll v5 | Smooth scroll + element detection; v5 is built on top of Lenis. |
| tsParticles / three-globe etc. | Particles and decorative effects. |
7.3 How to choose (a practical rule)
- Simple entrances and reveals alone? CSS scroll-driven animations (with a
@supportsfallback) or Motion'swhileInView. - A React app with animated UI (modals, lists, layout)? Motion.
- Scrollytelling, pin, horizontal scroll, split text? GSAP + ScrollTrigger (+ Lenis).
- The premium agency feel? Lenis + GSAP (the combo most used on Awwwards sites).
- A route transition? The View Transitions API; for artistic exit effects,
AnimatePresence. - Images with distortion or 3D? R3F or OGL.
- Motion and GSAP coexist well in the same project: Motion for UI, GSAP for the cinematic sections.
8. Implementation in Next.js
Everything below assumes Next.js 15/16 (App Router) + TypeScript + React 19.
8.0 Golden rules in the App Router
- Animation = a client component. Every component with GSAP/Motion/Lenis needs
"use client"at the top. Keep the pages as Server Components and isolate the animation in leaf components. - Touch
window/documentin no render. Effects belong inuseEffect/useGSAPalone. - Cleanup is mandatory. You have to destroy ScrollTriggers and listeners on unmount (a route change in the App Router reloads no page).
useGSAPhandles that on its own. - Fonts before SplitText. Await
document.fonts.readybefore splitting text. - Respect
prefers-reduced-motion(section 10). - Hydration: to avoid a content "flash" in the wrong initial state, set the initial state through CSS (a class with
opacity: 0, for example) or use Motion'sinitial, and avoid server/client divergence.
8.1 Package setup
npm i gsap @gsap/react lenis motion8.2 Global smooth scroll with Lenis
// app/providers/smooth-scroll.tsx
"use client";
import { ReactLenis } from "lenis/react";
export function SmoothScroll({ children }: { children: React.ReactNode }) {
return (
<ReactLenis
root
options={{
duration: 1.1,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
syncTouch: false, // touch keeps the native momentum
}}
>
{children}
</ReactLenis>
);
}// app/layout.tsx
import { SmoothScroll } from "./providers/smooth-scroll";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SmoothScroll>{children}</SmoothScroll>
</body>
</html>
);
}Lenis + ScrollTrigger integration (needed when you use both):
// app/providers/smooth-scroll.tsx (the version integrated with GSAP)
"use client";
import { ReactLenis, useLenis } from "lenis/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useEffect } from "react";
gsap.registerPlugin(ScrollTrigger);
function GsapSync() {
const lenis = useLenis(() => ScrollTrigger.update());
useEffect(() => {
if (!lenis) return;
// GSAP takes over the Lenis RAF
const raf = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
return () => gsap.ticker.remove(raf);
}, [lenis]);
return null;
}
export function SmoothScroll({ children }: { children: React.ReactNode }) {
return (
<ReactLenis root options={{ autoRaf: false, duration: 1.1 }}>
<GsapSync />
{children}
</ReactLenis>
);
}8.3 Reveal on scroll
With Motion (the fastest route):
"use client";
import { motion } from "motion/react";
export function Reveal({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.7, ease: [0.21, 0.47, 0.32, 0.98] }}
>
{children}
</motion.div>
);
}With GSAP + useGSAP (a batch for several elements):
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger, useGSAP);
export function RevealSection() {
const container = useRef<HTMLElement>(null);
useGSAP(
() => {
gsap.from(".card", {
y: 60,
opacity: 0,
duration: 0.8,
ease: "power3.out",
stagger: 0.12,
scrollTrigger: {
trigger: container.current,
start: "top 75%",
once: true,
},
});
},
{ scope: container } // selectors stay scoped to the container + automatic cleanup
);
return (
<section ref={container}>
<div className="card">…</div>
<div className="card">…</div>
<div className="card">…</div>
</section>
);
}100% CSS (progressive enhancement, zero JS):
@supports (animation-timeline: view()) {
.reveal {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal-up {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: none; }
}
}8.4 Parallax
With Motion (useScroll + useTransform):
"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
export function ParallaxImage({ src }: { src: string }) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"], // from the moment it enters until it leaves the viewport
});
const y = useTransform(scrollYProgress, [0, 1], ["-12%", "12%"]);
return (
<div ref={ref} style={{ overflow: "hidden", borderRadius: 12 }}>
<motion.img
src={src}
style={{ y, scale: 1.25, width: "100%", display: "block" }}
alt=""
/>
</div>
);
}With GSAP:
useGSAP(() => {
gsap.to(".parallax-img", {
yPercent: 20,
ease: "none",
scrollTrigger: {
trigger: ".parallax-wrap",
start: "top bottom",
end: "bottom top",
scrub: true,
},
});
}, { scope: container });8.5 A pinned section with a scrubbed timeline (scrollytelling)
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger, useGSAP);
export function PinnedStory() {
const container = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const tl = gsap.timeline({
scrollTrigger: {
trigger: container.current,
start: "top top",
end: "+=3000", // 3000px of "virtual" scroll for the narrative
scrub: 1, // 1s of smoothing
pin: true,
},
});
tl.to(".step-1", { opacity: 0, y: -40 })
.fromTo(".hero-img", { scale: 0.6 }, { scale: 1 }, "<")
.fromTo(".step-2", { opacity: 0, y: 40 }, { opacity: 1, y: 0 })
.to(".bg", { backgroundColor: "#0e0e10" }, "<");
},
{ scope: container }
);
return (
<div ref={container} className="bg" style={{ height: "100vh", position: "relative" }}>
<p className="step-1">First act…</p>
<img className="hero-img" src="/product.png" alt="" />
<p className="step-2" style={{ opacity: 0 }}>Second act…</p>
</div>
);
}8.6 Horizontal scroll
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger, useGSAP);
export function HorizontalGallery() {
const container = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const getDistance = () =>
(track.current?.scrollWidth ?? 0) - window.innerWidth;
gsap.to(track.current, {
x: () => -getDistance(),
ease: "none",
scrollTrigger: {
trigger: container.current,
start: "top top",
end: () => `+=${getDistance()}`, // vertical scroll = horizontal distance
scrub: 1,
pin: true,
invalidateOnRefresh: true, // recalculates on resize
},
});
},
{ scope: container }
);
return (
<section ref={container} style={{ overflow: "hidden" }}>
<div ref={track} style={{ display: "flex", gap: 24, width: "max-content" }}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} style={{ width: "60vw", height: "70vh", flexShrink: 0 }}>
Panel {i}
</div>
))}
</div>
</section>
);
}8.7 Split text reveal (GSAP's SplitText)
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP);
export function AnimatedHeadline({ text }: { text: string }) {
const ref = useRef<HTMLHeadingElement>(null);
useGSAP(async () => {
await document.fonts.ready; // avoids splitting with the wrong font
const split = SplitText.create(ref.current, {
type: "lines,words",
mask: "lines", // creates the per-line overflow:hidden (the curtain effect)
autoSplit: true, // re-splits on resize / font load
});
gsap.from(split.words, {
yPercent: 110,
duration: 0.9,
ease: "power4.out",
stagger: 0.04,
scrollTrigger: { trigger: ref.current, start: "top 80%", once: true },
});
});
return <h1 ref={ref}>{text}</h1>;
}The version without GSAP (a manual split + Motion):
"use client";
import { motion } from "motion/react";
export function WordReveal({ text }: { text: string }) {
return (
<h2 aria-label={text}>
{text.split(" ").map((word, i) => (
<span key={i} style={{ display: "inline-block", overflow: "hidden" }} aria-hidden>
<motion.span
style={{ display: "inline-block", marginRight: "0.3em" }}
initial={{ y: "110%" }}
whileInView={{ y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, ease: "easeOut", delay: i * 0.04 }}
>
{word}
</motion.span>
</span>
))}
</h2>
);
}8.8 A reading progress bar (Motion, 10 lines)
"use client";
import { motion, useScroll, useSpring } from "motion/react";
export function ReadingProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 120, damping: 30 });
return (
<motion.div
style={{
scaleX,
transformOrigin: "0 0",
position: "fixed",
inset: "0 0 auto 0",
height: 4,
background: "#6366f1",
zIndex: 50,
}}
/>
);
}8.9 Page transitions (View Transitions in Next.js 16)
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
viewTransition: true, // experimental: validate it before production use
},
};
export default nextConfig;// app/template.tsx — animates every route navigation
"use client";
import { unstable_ViewTransition as ViewTransition } from "react";
export default function Template({ children }: { children: React.ReactNode }) {
return <ViewTransition>{children}</ViewTransition>;
}/* globals.css — customizes the default transition */
::view-transition-old(root) {
animation: fade-out 0.25s ease both;
}
::view-transition-new(root) {
animation: fade-in 0.35s ease 0.05s both;
}
@keyframes fade-out { to { opacity: 0; transform: translateY(-8px); } }
@keyframes fade-in { from { opacity: 0; transform: translateY(8px); } }For the thumbnail → detail effect (a shared element), give the element the same view-transition-name on both routes:
.product-card img { view-transition-name: var(--vt-name); }8.10 Video with autoplay by visibility
"use client";
import { useEffect, useRef } from "react";
export function AutoPlayVideo({ src }: { src: string }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = ref.current;
if (!video) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) video.play().catch(() => {});
else video.pause();
},
{ threshold: 0.5 }
);
io.observe(video);
return () => io.disconnect();
}, []);
return <video ref={ref} src={src} muted loop playsInline preload="metadata" />;
}8.11 A scrubbed image sequence (the Apple style)
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger, useGSAP);
const FRAME_COUNT = 120;
const frameSrc = (i: number) =>
`/sequence/frame-${String(i + 1).padStart(4, "0")}.webp`;
export function ScrollSequence() {
const container = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useGSAP(
() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d")!;
canvas.width = 1600;
canvas.height = 900;
const images: HTMLImageElement[] = [];
const state = { frame: 0 };
for (let i = 0; i < FRAME_COUNT; i++) {
const img = new Image();
img.src = frameSrc(i);
images.push(img);
}
const render = () => {
const img = images[Math.round(state.frame)];
if (img?.complete) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
};
images[0].onload = render;
gsap.to(state, {
frame: FRAME_COUNT - 1,
snap: "frame",
ease: "none",
onUpdate: render,
scrollTrigger: {
trigger: container.current,
start: "top top",
end: "+=2500",
scrub: 0.5,
pin: true,
},
});
},
{ scope: container }
);
return (
<div ref={container} style={{ height: "100vh" }}>
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
);
}8.12 Stacked cards (Motion + sticky)
"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform, MotionValue } from "motion/react";
function Card({ i, total, progress, children }: {
i: number; total: number; progress: MotionValue<number>; children: React.ReactNode;
}) {
const start = i / total;
const scale = useTransform(progress, [start, 1], [1, 1 - (total - i) * 0.04]);
return (
<motion.div
style={{
scale,
position: "sticky",
top: `calc(8vh + ${i * 24}px)`,
transformOrigin: "top center",
}}
>
{children}
</motion.div>
);
}
export function StackedCards({ items }: { items: string[] }) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
return (
<div ref={ref}>
{items.map((item, i) => (
<Card key={i} i={i} total={items.length} progress={scrollYProgress}>
<div style={{ height: "70vh", borderRadius: 16, background: "#18181b" }}>{item}</div>
</Card>
))}
</div>
);
}8.13 Avoiding the classic mistakes in Next.js
| Mistake | Cause | Fix |
|---|---|---|
window is not defined | GSAP/Lenis running on the server | "use client" + logic inside useEffect/useGSAP |
| "Ghost" animations after a route change | Old ScrollTriggers never destroyed | useGSAP with scope (automatic cleanup) or ScrollTrigger.getAll().forEach(t => t.kill()) in the cleanup |
| Wrong trigger positions | Images/fonts loading after the calculation | ScrollTrigger.refresh() after load; fixed dimensions on the images (next/image with width/height) |
| A content flash before the initial state | An initial state applied through JS alone | The initial state in CSS or Motion's initial prop |
| Sticky broken with Lenis | overflow-x: hidden on the body | use overflow-x: clip |
| Video scrub stuttering | An encode without frequent keyframes | re-encode (-g 1) or move to an image sequence |
| A bloated bundle | Importing unused plugins / the whole lib | import the needed parts alone; LazyMotion in Motion; a dynamic import for heavy sections (R3F) |
9. Reference and Inspiration Sites
9.1 Award galleries and curation (the essentials)
| Site | What you find |
|---|---|
| Awwwards (awwwards.com) | The main award for creative web design. Filters by technology (GSAP, Three.js) and by style. The "Site of the Day" is the market's yardstick. |
| Godly (godly.website) | Lean, modern curation, strong on motion and landing pages. Each entry carries a preview video. |
| FWA (thefwa.com) | An award focused on immersive and experimental experiences (heavy WebGL). |
| CSS Design Awards (cssdesignawards.com) | An alternative award to Awwwards, with judges from the industry. |
| SiteInspire (siteinspire.com) | Classic curation with filters by style and segment. |
| Httpster (httpster.net) | Curation with personality, focused on typography and bold sites. |
| Minimal Gallery (minimal.gallery) | Minimalist sites, well executed (a good counterpoint: restrained, elegant motion). |
| Land-book (land-book.com) and Curated.design | Landing pages and product sites, great for commercial/SaaS reference. |
| Savee (savee.it) | A collaborative moodboard with plenty of motion and art-direction material. |
| Dark Mode Design, Footer.design, Seesaw (seesaw.website) | Niche curation, useful for specific details. |
9.2 Technical reference with code
| Site | What you find |
|---|---|
| Codrops (tympanus.net/codrops) | Tutorials and demos of advanced effects (scroll, WebGL, typography) with source code. The best practical school of web motion. |
| CodePen (codepen.io) | Thousands of ScrollTrigger, split text and parallax demos. Search for "ScrollTrigger", "Lenis", "scroll-driven". |
| Osmo (osmo.supply) | A library of ready-made motion components and effects, built by devs with Awwwards awards. |
| GSAP Showcase and Demos (gsap.com/showcase) | Real sites built with GSAP + the official demo collection per plugin. |
| Motion Examples (motion.dev) | 400+ official copy-paste examples from Motion. |
| Olivier Larose (blog.olivierlarose.com) | Video tutorials + code for effects from award-winning sites in Next.js (zoom parallax, text reveal, curved menus). |
| Frontend Horse (frontend.horse) and Codrops Collective | Breakdowns of how effects from award-winning sites were built. |
| scroll-driven-animations.style | The official reference site (Bramus/Chrome) with demos and tools for CSS scroll-driven animations. |
9.3 Product/studio sites worth studying (real patterns)
- Apple.com (the product pages): the reference for scrubbed image sequences and narrative pinning.
- Linear.app, Vercel.com, Resend.com, Stripe.com: restrained, functional product motion for SaaS.
- Award-winning studios: Locomotive (locomotive.ca), Obys (obys.agency), Basement Studio (basement.studio), Unseen Studio, Active Theory, Lusion (lusion.co), Darkroom Engineering (darkroom.engineering). Analyze them with DevTools: Lenis + GSAP sit underneath almost every time.
10. Performance and Accessibility
10.1 Performance
- Animate
transformandopacityalone. They compose on the GPU with no reflow or repaint. Avoid animatingwidth,height,top,left,margin,box-shadow(animate a pseudo-element with opacity for shadows). - Prefer the compositor. CSS scroll-driven animations and Motion's hybrid engine (WAAPI/ScrollTimeline) run off the main thread and stay fluid even under heavy JS.
will-changein moderation. Apply it to what will animate and remove it afterward; in excess, it eats memory.- Never use raw
scrolllisteners for animation. Use ScrollTrigger/useScroll (which optimize with RAF already) or native CSS. - Measure: DevTools > Performance (look for long tasks and jank), the FPS meter, and test on a mid-range Android, rather than on your desktop alone.
- Images and video:
next/image, WebP/AVIF, smartpreloadof the sequences, a poster on videos. - Code splitting:
next/dynamicfor WebGL/3D sections;LazyMotion+ themcomponent to shrink the Motion bundle.
10.2 Accessibility
prefers-reduced-motionis mandatory. Users with vestibular sensitivity can get sick from parallax and zoom.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}// In React (Motion)
import { useReducedMotion } from "motion/react";
const reduce = useReducedMotion();
// use `reduce` to swap slide/parallax for a plain fade (or for nothing)In GSAP, use gsap.matchMedia() with the (prefers-reduced-motion: reduce) condition to register alternative versions.
- Content can depend on the animation in no way. Without JS (or with reduced motion), everything has to stay readable.
- Split text needs an
aria-labelon the parent element (loose spans turn into noise in a screen reader). Modern GSAP SplitText handles that already. - Horizontal scroll and pin: guarantee keyboard navigation and hijack the scroll no further than needed.
- Video autoplay always
muted, and with a visible pause control in the ideal case for long videos.
11. A Study Path
A suggested order, from the fundamentals to the advanced:
- Weeks 1-2, fundamentals: CSS transitions/animations, easing,
transform/opacity,IntersectionObserver. Implement reveals with pure CSS scroll-driven animations. - Weeks 3-4, Motion:
whileInView, variants + stagger,AnimatePresence,layout, thenuseScroll+useTransform(progress bar, parallax, stacked cards). - Weeks 5-7, GSAP: tweens, timelines, easing; then ScrollTrigger in depth (trigger, scrub, pin, snap,
matchMedia). Rebuild: horizontal scroll, a narrative pinned section, an image sequence. - Week 8, text: SplitText (masked lines, chars with stagger), progressive text reveal, a marquee with velocity.
- Week 9, integration: Lenis + GSAP + Next.js App Router in a real project (a portfolio or landing page). Add View Transitions.
- Ongoing: pick 1 site from Awwwards/Godly each week and rebuild 1 effect from it. It is the method that accelerates learning most (Codrops and Olivier Larose help a lot here).
Document produced on 01/08/2026. Reference versions: GSAP 3.13+ (free, every plugin), Motion v12+ (motion/react), Lenis 1.3+ (lenis/react), Next.js 15/16 (App Router), React 19.2.
Read next
The definitive guide — a Next.js site built around motion and scroll
The scroll foundation that, when missing, keeps the animations from working at all: Lenis, GSAP and Next.js wired in the right order and the mistakes to avoid.
- next.js
- lenis
Infra •
Documentation: deploying a Next.js application with GitHub + Hostinger
Every push becomes a live site with no hosting panel involved: connecting GitHub to Hostinger, the build settings that break and the checks after each deploy.
- deploy
- github
Instituto +Brasal redesign — complete documentation of the process
From analyzing the skills catalog to running six competing stacks on the Instituto +Brasal redesign, with every prompt used and what each stack delivered.
- process
- skills
- part 1/2


