All components

Mat Voyce Scroll Animation

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A pinned scroll section (scrub over 5x viewport height, Lenis-smoothed) drives a horizontal reel of four huge italic project titles while ten rounded image cards fly toward the viewer from deep translateZ. A single ScrollTrigger onUpdate maps progress to the titles' x offset and, per card, staggers each one's Z position and scale; scroll velocity adds a springy layered x-jitter to two color copies of every title for a Mat Voyce style kinetic-type effect.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll pin scrub kinetic-typography 3d-perspective translatez velocity lenis playful

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Kinetic Type Scroll — pinned horizontal title reel + 3D fly-through cards

Goal

Build a full-page scroll experience: a pinned section where four giant italic uppercase project titles scroll horizontally as a 400vw reel while ten rounded image cards fly toward the camera from extremely deep translateZ. Each title is three stacked color copies, and the two top copies jitter horizontally based on live scroll velocity — a kinetic-typography "ink offset" effect.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scrolling. No other libraries.

Wire Lenis to GSAP exactly like this: create new Lenis(), call ScrollTrigger.update on its scroll event, drive it from GSAP's ticker with lenis.raf(time * 1000), and call gsap.ticker.lagSmoothing(0). Run everything inside a DOMContentLoaded handler.

Layout / HTML

  • <nav> — fixed, top-left, full width, z-index: 2, padding 2em, flex with gap: 4em. Three children:
  • .logo (flex: 3) containing .logo-img (width 120px) with an <img> logo.
  • .tagline (flex: 1): a <p> reading "Your go-to creative powerhouse for" then a line break and a <span> "design, branding, and motion." (span in gray).
  • .about (flex: 1): two <p>: "Headquartered in Toronto" and a <span> "Collaborating worldwide" (gray).
  • <section class="hero"> — 100vw × 100vh, centered <h1>(Scroll if you dare)</h1>.
  • <section class="sticky"> — 100vw × 100vh, contains:
  • <div class="titles"> with four <div class="title"> blocks. Each block holds THREE stacked copies of the same heading: <h1 class="title-1">, <h1 class="title-2">, <h1 class="title-3">. The four headings read: "Showcase Hub", "Nova Stream", "Circle 30", "Bites & Banter".
  • <div class="images"> — empty in HTML; the 10 cards are created by JS.
  • <section class="outro"> — 100vw × 100vh, centered <h1>(That's a wrap)</h1>.

All sections: position: relative; width: 100vw; height: 100vh; overflow: hidden;.

Styling

  • Reset: * { margin: 0; padding: 0; box-sizing: border-box; }. All img { width: 100%; height: 100%; object-fit: cover; }.
  • Typeface: a heavy condensed display font. Use font-family: "F37 Judge", "Anton", Impact, sans-serif on body (the original uses an F37 Judge–style condensed grotesque; any bold condensed sans is an acceptable substitute). Nav p are font-weight: 500, antialiased; nav p span { color: gray; }.
  • h1 global: text-transform: uppercase; font-size: 4vw; font-weight: 500; color: #1f1f1f;.
  • Palette: .hero and .outro background #edebde (warm cream); .sticky background #fffef8 (near-white).
  • .titles: position: absolute; top: 0; left: 0; width: 400vw; height: 100vh; display: flex; will-change: transform;. Each .title: position: relative; flex: 1; flex-centered. Each .title h1: position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 9vw; font-style: italic; will-change: transform; — the three copies sit exactly on top of each other.
  • Title copy colors (stacking order matters — .title-3 is last in DOM so it paints on top): h1.title-1 { color: #dafa6c; } (lime), h1.title-2 { color: #10d0f4; } (cyan), h1.title-3 { color: #1f1f1f; } (near-black).
  • .images: position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 200vw; height: 200vh; transform-style: preserve-3d; perspective: 2000px; z-index: -1; — it sits BEHIND the titles.
  • .card: position: absolute; width: 200px; height: 200px; border-radius: 2em; background: #c0c0c0; transform-style: preserve-3d; will-change: transform; overflow: hidden;.

GSAP effect (exhaustive)

Register ScrollTrigger.

Card creation (JS, before animation)

Create 10 cards in a loop (card card-1card card-10), each containing an <img> (images 1–10). Position each card via inline top/left percentages, in this exact order:

{ top: 30%, left: 55% }, { top: 20%, left: 25% }, { top: 50%, left: 10% },
{ top: 60%, left: 40% }, { top: 30%, left: 30% }, { top: 60%, left: 60% },
{ top: 20%, left: 50% }, { top: 60%, left: 10% }, { top: 20%, left: 40% },
{ top: 45%, left: 55% }

Append them to .images. Then initialize every card with gsap.set(card, { z: -50000, scale: 0 }) — they start 50,000px deep into the scene and invisible.

ScrollTrigger

One single ScrollTrigger.create({...}) drives everything:

  • trigger: ".sticky", start: "top top", end: "+=" + (window.innerHeight * 5) + "px" (5 viewport heights of scroll), pin: true, scrub: 1.
  • All animation happens in onUpdate(self) — there is no timeline; values are computed from self.progress (0→1) each tick.
1) Horizontal title reel

const moveDistance = window.innerWidth * 3 (the 400vw strip minus one screen). Every update: gsap.set(".titles", { x: -moveDistance * self.progress }). So the reel translates from x: 0 to x: -3 × viewportWidth linearly with progress, smoothed only by scrub: 1 and Lenis.

2) Velocity-based kinetic type jitter

Each update, read const velocity = self.getVelocity(), then:

  • normalizedVelocity = velocity / Math.abs(velocity) || 0 (just the sign: -1, 0 or 1).
  • maxOffset = 30; currentSpeed = Math.min(Math.abs(velocity / 500), maxOffset).
  • isAtEdge = self.progress <= 0 || self.progress >= 1.

For each of the four .title blocks, grab its .title-1, .title-2, .title-3:

  • If isAtEdge: snap the two color copies back — gsap.to([title1, title2], { xPercent: -50, x: 0, duration: 0.3, ease: "power2.out", overwrite: true }).
  • Otherwise, with baseOffset = normalizedVelocity * currentSpeed:
  • gsap.to(title1, { xPercent: -50, x: baseOffset * 4 + "px", duration: 0.2, ease: "power1.out", overwrite: "auto" }) (lime copy drifts furthest),
  • gsap.to(title2, { xPercent: -50, x: baseOffset * 2 + "px", duration: 0.2, ease: "power1.out", overwrite: "auto" }) (cyan copy drifts half as far).
  • Always: gsap.set(title3, { xPercent: -50, x: 0 }) — the dark top copy never moves.

xPercent: -50 keeps every copy horizontally centered (GSAP picks up the -50% vertical translate from the CSS transform), so the colored copies fan out behind the dark one only while scrolling fast, then spring back together.

3) 3D card fly-through (staggered by index, computed per tick)

For each card index (0–9), inside the same onUpdate:

  • staggerOffset = index * 0.075 — each card starts its journey 7.5% of total progress after the previous one.
  • scaledProgress = (self.progress - staggerOffset) * 3 — each card completes its full flight in one third of the scroll range.
  • individualProgress = clamp(scaledProgress, 0, 1).
  • targetZ = 2000 for cards 0–8, but 1500 for the LAST card (index 9) — since the perspective is 2000px, the first nine fly PAST the camera and out of view, while the final card stops just short and ends up covering the screen.
  • newZ = -50000 + (targetZ + 50000) * individualProgress — linear interpolation from -50000 to targetZ.
  • scale = clamp(individualProgress * 10, 0, 1) — the card pops from scale 0 to 1 within the first 10% of its own flight, then Z does the rest of the growing via perspective.
  • Apply with gsap.set(card, { z: newZ, scale: scale }) (no tween — direct set every tick).

The result: cards emerge as tiny dots in the far distance, rush toward the viewer one after another and blow past the camera, while the title reel slides left; releasing the scroll makes the color-fringed titles settle back into a single solid word.

Assets / images

  • 1 logo: a bold black italic outlined wordmark on transparent background (fictional studio name, e.g. "Kota Verge"), placed in the nav, ~120px wide.
  • 10 photographic images for the cards, roughly square (they render in 200×200 rounded-corner cards with object-fit: cover, so 1:1 is ideal). Varied colorful editorial/lifestyle photos work best.

Behavior notes

  • The pinned section consumes 5 viewport heights of scroll; total page = hero + pinned distance + outro.
  • Everything is driven by one ScrollTrigger's onUpdate — no timelines, no stagger config; the stagger is hand-computed from index * 0.075.
  • moveDistance and end are captured from window.innerWidth / window.innerHeight at load (no resize handling needed).
  • Keep will-change: transform on .titles, .title h1 and .card for smoothness; z-index: -1 on .images keeps cards behind the titles, and overflow: hidden on sections prevents scrollbar blowout from the 400vw strip.