GTA-VI-style Scroll Logo Reveal — Pinned Hero with Shrinking SVG Mask
Goal
Build a cinematic, full-page landing hero in the style of the GTA VI trailer site: as you scroll, a giant dark overlay punched with a logo-shaped SVG mask hole shrinks exponentially (scale 500 → 1), so the dark screen "closes in" until only a logo-shaped window into the scene remains; meanwhile the layered hero artwork zooms out, a white overlay blooms behind the mask, and a big headline is unveiled with a bottom-up gradient wipe. Everything is driven by a single pinned, scrubbed ScrollTrigger smoothed by Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis for smooth scrolling. No other libraries.
Layout / HTML
body
├── section.hero
│ ├── div.hero-img-container
│ │ ├── img ← image 1: full-bleed background scene (JPG)
│ │ ├── div.hero-img-logo
│ │ │ └── img ← image 3: white wordmark logo (transparent PNG)
│ │ ├── img ← image 2: foreground subjects cut out (transparent PNG),
│ │ │ stacked AFTER the logo so the subjects sit in front of it
│ │ └── div.hero-img-copy
│ │ └── p "Scroll down to reveal"
│ ├── div.fade-overlay
│ ├── div.overlay
│ │ └── svg (width="100%" height="100%")
│ │ ├── defs > mask#logoRevealMask
│ │ │ ├── rect width=100% height=100% fill="white"
│ │ │ └── path#logoMask ← no fill attribute (defaults to black = hole)
│ │ └── rect width=100% height=100% fill="#111117" mask="url(#logoRevealMask)"
│ ├── div.logo-container ← empty; geometric reference box for fitting the mask
│ └── div.overlay-copy
│ └── h1 "Animation <br> Experiment 452 <br> By Motionprompts"
└── section.outro
└── p "Build your empire. Rule your city."
The mask trick: inside <mask>, the white rect keeps the dark #111117 rect visible everywhere, and the black logo path punches a transparent hole through it. Scaling the whole .overlay element scales the hole.
Logo mask path data
Keep a logo.js module exporting export const logoData = "M…Z" — a single SVG path string drawing a chunky, bold two-letter wordmark (e.g. the Roman numerals "VI") as closed polygon subpaths (straight M/L/Z commands are fine, one subpath per letter stroke). The exact coordinate system does not matter: a fitting function normalizes the path via getBBox() at runtime. Make the letterforms thick and poster-like so the final hole reads clearly.
Styling
- Google font DM Sans (import from Google Fonts). Global reset (
* { margin:0; padding:0; box-sizing:border-box }). body:background: #111117,color: #fff,overflow-x: hidden.- All
img:width:100%; height:100%; object-fit:cover. h1: uppercase,font-size: 6rem, weight 700,letter-spacing: -0.2rem,line-height: 0.8.p: uppercase,font-size: 1.25rem, weight 500,line-height: 0.8.- Every
section:position: relative; width: 100vw; height: 100vh; background-color: #111117; text-align: center; overflow: hidden. .hero-img-container, its directimgs, and.fade-overlay:position: absolute; top:0; left:0; width:100%; height:100%(full-bleed stack)..hero-img-logo img:position: absolute; top: 25%; left: 50%; transform: translate(-50%, 0); width: 250px; height: auto; object-fit: contain(overrides the global img rule)..hero-img-copy:position: absolute; bottom: 20%; left: 50%; transform: translate(-50%, 0); will-change: opacity; itspisfont-size: 0.65rem..fade-overlay:background-color: #fff; will-change: opacity;start it atopacity: 0(scroll drives it)..overlay:position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 1; transform-origin: center center. (If you declare it differently in CSS, force these inline from JS on load:width 100vw / height 100vh / position fixed / top 0 / left 0 / transform none.).logo-container:position: fixed; top: 25%; left: 50%; transform: translate(-50%, -50%); transform-origin: center center; width: 400px; height: 400px; z-index: 2. It stays empty — it only defines where/how big the mask hole ends up..overlay-copy:position: absolute; bottom: 25%; left: 50%; transform: translate(-50%, 0); z-index: 2; width: 100%..overlay-copy h1:background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; color: transparent; transform-origin: center 0%(the JS paints a gradient into itsbackground)..outro: flex, centered both axes.@media (max-width: 900px):h1 { font-size: 2rem; letter-spacing: 0 },p { font-size: 1rem },.overlay-copy { width: 100% }.
GSAP effect (the core — follow exactly)
Everything runs after DOMContentLoaded. gsap.registerPlugin(ScrollTrigger).
Lenis setup
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Mask fitting (run on load and on resize)
Set the d attribute of path#logoMask to logoData. Then updateLogoMask():
logoDimensions = logoContainer.getBoundingClientRect();logoBoundingBox = logoMask.getBBox().scaleFactor = Math.min(logoDimensions.width / bbox.width, logoDimensions.height / bbox.height)(contain-fit).- Translation centers the scaled path inside the container box, compensating for the bbox origin:
x = logoDimensions.left + (logoDimensions.width − bbox.width × scaleFactor) / 2 − bbox.x × scaleFactory = logoDimensions.top + (logoDimensions.height − bbox.height × scaleFactor) / 2 − bbox.y × scaleFactorlogoMask.setAttribute("transform",translate(${x}, ${y}) scale(${scaleFactor})).
Initial state
const initialOverlayScale = 500;
gsap.set(svgOverlay, {
transformOrigin: "50% 50%",
xPercent: 0, yPercent: 0, left: 0, top: 0,
scale: initialOverlayScale,
});
At scale 500 the mask hole is astronomically larger than the viewport, so the dark rect is effectively invisible on load and the hero artwork shows through.
The single ScrollTrigger
Created by a setupScrollTrigger() function that kills any previous instance first (stored in a variable), then:
ScrollTrigger.create({
trigger: ".hero",
start: "top top",
end: `+=${window.innerHeight * 5}px`, // 5× viewport of scroll distance
pin: true,
pinSpacing: true,
scrub: 1,
onUpdate: (self) => { /* all animation below, driven by self.progress */ },
});
There are no tweens/timelines — every value is computed from progress (0→1) inside onUpdate and applied with gsap.set. The phases:
Phase A — intro fade (progress 0 → 0.15). heroImgLogo (the .hero-img-logo div) and .hero-img-copy fade out linearly: opacity = 1 − progress × (1 / 0.15). For progress > 0.15, hard-set both to opacity: 0.
Phase B — zoom-out + mask shrink (progress 0 → 0.85). Only while progress <= 0.85, with n = progress / 0.85 (normalized 0→1):
.hero-img-containerscale:1.5 − 0.5 × n— i.e. linear 1.5 → 1.0 (the artwork starts zoomed in and settles)..overlayscale: exponential decayoverlayScale = 500 × (1/500)^n— 500 → 1 (equal ratio per unit progress, so the shrink feels constant on a log scale rather than slamming shut). Apply withgsap.set(svgOverlay, { transformOrigin: "50% 25%", scale: overlayScale, force3D: true })— note the transform origin switches to50% 25%here so the hole converges on the logo-container position (top 25% of the viewport)..fade-overlayopacity:0untilprogress ≥ 0.25, thenmin(1, (progress − 0.25) / 0.4)— linear 0 → 1 between progress 0.25 and 0.65. The scene whites out behind the shrinking hole, so the end state reads as a white logo mark on a dark screen.
Phase C — headline gradient wipe (progress 0.7 → 0.85). With r = (progress − 0.7) / 0.15 (0→1):
- Paint the
h1background each frame:
gradientSpread = 100; gradientBottomPosition = 240 − r × 280 (240% → −40%); gradientTopPosition = gradientBottomPosition − gradientSpread. h1.style.background = \linear-gradient(to bottom, #111117 0%, #111117 ${top}%, #e66461 ${bottom}%, #e66461 100%)\` and h1.style.backgroundClip = "text". Because the text is fill-transparent with background-clip: text, this sweeps a coral (#e66461) fill up through dark (#111117`) letters, bottom to top.
gsap.set(h1, { scale: 1.25 − 0.25 × r, opacity: r })— scales 1.25 → 1 (transform-origincenter 0%) while fading 0 → 1.- For
progress < 0.7, keeph1atopacity: 0.
After progress 0.85 everything holds its final state while the pin plays out, then the page releases into the .outro section.
Resize handling
On window.resize: call updateLogoMask(), ScrollTrigger.refresh(), and setupScrollTrigger() again (kill + recreate so the end distance tracks the new viewport height).
Assets / images
Three images (describe roles, no brands):
- Background scene — full-bleed cinematic landscape artwork (~16:9, JPG): a warm sunset/neon-sky vista in a stylized poster look (e.g. characters by a vintage car with palm trees and a pink-purple sky). Bottom layer.
- Foreground cut-out — the same subjects isolated on a transparent background (PNG), pixel-aligned with image 1 so that stacking them recreates the scene with the subjects on a separate layer (they overlap the intro logo).
- Wordmark logo — a white logo/wordmark on a transparent background (PNG), shown centered at the top quarter of the hero before it fades out.
Behavior notes
- Page-level effect: it owns the scroll (Lenis) and pins the hero for 5 viewport heights; total page = pinned hero + one normal outro screen.
- No autoplay animation — everything is scrubbed; scrolling back up reverses every phase perfectly.
- The mask fit is fully responsive: any logo path works at any viewport because
updateLogoMask()re-measures on resize. - Works without WebGL/canvas; keep the dark
#111117background during load to avoid flashes.