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, padding2em, flex withgap: 4em. Three children:.logo(flex: 3) containing.logo-img(width120px) 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; }. Allimg { width: 100%; height: 100%; object-fit: cover; }. - Typeface: a heavy condensed display font. Use
font-family: "F37 Judge", "Anton", Impact, sans-serifonbody(the original uses an F37 Judge–style condensed grotesque; any bold condensed sans is an acceptable substitute). Navparefont-weight: 500, antialiased;nav p span { color: gray; }. h1global:text-transform: uppercase; font-size: 4vw; font-weight: 500; color: #1f1f1f;.- Palette:
.heroand.outrobackground#edebde(warm cream);.stickybackground#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-3is 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-1 … card 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 fromself.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 = 2000for cards 0–8, but1500for 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 fromindex * 0.075. moveDistanceandendare captured fromwindow.innerWidth/window.innerHeightat load (no resize handling needed).- Keep
will-change: transformon.titles,.title h1and.cardfor smoothness;z-index: -1on.imageskeeps cards behind the titles, andoverflow: hiddenon sections prevents scrollbar blowout from the 400vw strip.