Skip to content
Zumkai

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
A holographic gradient in blue, orange and magenta, in fluid movement
Contents
  1. Contents
  2. 1. Fundamentals
  3. 2. Scroll Motion Techniques
  4. 3. Motion Techniques for Text
  5. 4. Motion Techniques for Images
  6. 5. Motion Techniques for Video
  7. 6. Page Transitions and Micro-interactions
  8. 7. Libraries and Tools
  9. 8. Implementation in Next.js
  10. 9. Reference and Inspiration Sites
  11. 10. Performance and Accessibility
  12. 11. A Study Path

Scroll, Text, Images and Video + Implementation in Next.js Updated August 2026

Contents

  1. Fundamentals: the 2 kinds of scroll animation
  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 (what each one does)
  8. Implementation in Next.js (setup + code per technique)
  9. Reference and Inspiration Sites
  10. Performance and Accessibility
  11. 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), ScrollTrigger without scrub (GSAP), IntersectionObserver, CSS animation-timeline: view() with a short animation-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: ScrollTrigger with scrub (GSAP), useScroll + useTransform (Motion), CSS animation-timeline: scroll().

1.3 Concepts you will use all the time

ConceptWhat it is
Trigger / start / endThe viewport point where the animation begins and ends (start: "top 80%", for example)
ScrubTying timeline progress to the scroll (with or without smoothed "lag", scrub: 1 for example)
PinFixing an element on screen while a scroll range happens "underneath" it
StaggerAn 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
EasingThe acceleration curve (ease-out for entrances, ease-in-out for movements)
TimelineAn orchestrated sequence of tweens with control over overlap and order
FLIPThe First-Last-Invert-Play technique for animating layout changes with performance
Viewport amount / thresholdHow 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: whileInView in Motion, ScrollTrigger in GSAP, or 100% CSS with animation-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), ScrollTrigger with scrub (GSAP), or pure CSS with animation-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: true in ScrollTrigger (the market standard), or position: 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: hidden on the body breaks position: sticky with Lenis; use overflow-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().scrollYProgressscaleX (Motion, 5 lines), or pure CSS with animation-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's snap to settle on timeline points.
  • Careful: aggressive fullpage snap frustrates users; use proximity in place of mandatory where 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: X on each card + scale/brightness driven by the scroll (Motion useScroll with target on the container, or ScrollTrigger, or CSS view()).

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 scale and clip-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 exposes velocity).

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 x from 0 to -50% in a loop (CSS or GSAP xPercent with wrap).

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 an aria-label on 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 animated background-position; or video with a mask/SVG <clipPath> in the shape of text.

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 with snap: 1, fired by whileInView/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 an overflow: hidden container, 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.

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.

A trail of images following the cursor (each movement "stamps" an image that scales and fades). The hero effect of creative sites.

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 layout prop 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 → drawImage on 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 1 for 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's useInView) calling video.play()/pause(). The video needs muted + playsInline for 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)

ToolWhat it doesWhen 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.xSmooth 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

ToolWhat it does
React Three Fiber + dreiDeclarative Three.js in React. 3D scenes, shaders, WebGL. drei brings helpers (ScrollControls, cameras, materials). For 3D heroes and immersive experiences.
OGL / curtains.jsMinimalist WebGL for image effects (hover distortion, displacement) without the weight of Three.js.
anime.js v4A light, elegant JS library for property animation, SVG and timelines. A smaller alternative to GSAP for simple and medium cases.
React SpringSpring-physics animation in React. An alternative to Motion with an "everything is a spring" philosophy.
Lottie (lottie-react) / RivePlayback 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 CarouselCarousels 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.jsA visual timeline editor for complex choreography (3D included) with a keyframe interface.
barba.jsPage transitions on non-React multi-page sites.
Locomotive Scroll v5Smooth 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)

  1. Simple entrances and reveals alone? CSS scroll-driven animations (with a @supports fallback) or Motion's whileInView.
  2. A React app with animated UI (modals, lists, layout)? Motion.
  3. Scrollytelling, pin, horizontal scroll, split text? GSAP + ScrollTrigger (+ Lenis).
  4. The premium agency feel? Lenis + GSAP (the combo most used on Awwwards sites).
  5. A route transition? The View Transitions API; for artistic exit effects, AnimatePresence.
  6. Images with distortion or 3D? R3F or OGL.
  7. 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

  1. 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.
  2. Touch window/document in no render. Effects belong in useEffect/useGSAP alone.
  3. Cleanup is mandatory. You have to destroy ScrollTriggers and listeners on unmount (a route change in the App Router reloads no page). useGSAP handles that on its own.
  4. Fonts before SplitText. Await document.fonts.ready before splitting text.
  5. Respect prefers-reduced-motion (section 10).
  6. 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's initial, and avoid server/client divergence.

8.1 Package setup

bash
npm i gsap @gsap/react lenis motion

8.2 Global smooth scroll with Lenis

tsx
// 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>
  );
}
tsx
// 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):

tsx
// 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):

tsx
"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):

tsx
"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):

css
@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):

tsx
"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:

tsx
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)

tsx
"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

tsx
"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)

tsx
"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):

tsx
"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)

tsx
"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)

ts
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    viewTransition: true, // experimental: validate it before production use
  },
};
export default nextConfig;
tsx
// 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>;
}
css
/* 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:

css
.product-card img { view-transition-name: var(--vt-name); }

8.10 Video with autoplay by visibility

tsx
"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)

tsx
"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)

tsx
"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

MistakeCauseFix
window is not definedGSAP/Lenis running on the server"use client" + logic inside useEffect/useGSAP
"Ghost" animations after a route changeOld ScrollTriggers never destroyeduseGSAP with scope (automatic cleanup) or ScrollTrigger.getAll().forEach(t => t.kill()) in the cleanup
Wrong trigger positionsImages/fonts loading after the calculationScrollTrigger.refresh() after load; fixed dimensions on the images (next/image with width/height)
A content flash before the initial stateAn initial state applied through JS aloneThe initial state in CSS or Motion's initial prop
Sticky broken with Lenisoverflow-x: hidden on the bodyuse overflow-x: clip
Video scrub stutteringAn encode without frequent keyframesre-encode (-g 1) or move to an image sequence
A bloated bundleImporting unused plugins / the whole libimport 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)

SiteWhat 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.designLanding 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

SiteWhat 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 CollectiveBreakdowns of how effects from award-winning sites were built.
scroll-driven-animations.styleThe 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

  1. Animate transform and opacity alone. They compose on the GPU with no reflow or repaint. Avoid animating width, height, top, left, margin, box-shadow (animate a pseudo-element with opacity for shadows).
  2. 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.
  3. will-change in moderation. Apply it to what will animate and remove it afterward; in excess, it eats memory.
  4. Never use raw scroll listeners for animation. Use ScrollTrigger/useScroll (which optimize with RAF already) or native CSS.
  5. 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.
  6. Images and video: next/image, WebP/AVIF, smart preload of the sequences, a poster on videos.
  7. Code splitting: next/dynamic for WebGL/3D sections; LazyMotion + the m component to shrink the Motion bundle.

10.2 Accessibility

  1. prefers-reduced-motion is mandatory. Users with vestibular sensitivity can get sick from parallax and zoom.
css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
tsx
// 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.

  1. Content can depend on the animation in no way. Without JS (or with reduced motion), everything has to stay readable.
  2. Split text needs an aria-label on the parent element (loose spans turn into noise in a screen reader). Modern GSAP SplitText handles that already.
  3. Horizontal scroll and pin: guarantee keyboard navigation and hijack the scroll no further than needed.
  4. 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:

  1. Weeks 1-2, fundamentals: CSS transitions/animations, easing, transform/opacity, IntersectionObserver. Implement reveals with pure CSS scroll-driven animations.
  2. Weeks 3-4, Motion: whileInView, variants + stagger, AnimatePresence, layout, then useScroll + useTransform (progress bar, parallax, stacked cards).
  3. 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.
  4. Week 8, text: SplitText (masked lines, chars with stagger), progressive text reveal, a marquee with velocity.
  5. Week 9, integration: Lenis + GSAP + Next.js App Router in a real project (a portfolio or landing page). Add View Transitions.
  6. 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.