All components

GTA VI Landing Page Scroll

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

Open live demo ↗ Raw prompt (.md)

What it does

A GTA-VI-style pinned hero reveal driven by scroll, art-directed as 'Casa Vicente', a Lisbon tailoring house AW26 campaign. A single ScrollTrigger (pin + scrub over 5x viewport height, Lenis-smoothed) fades out the intro wordmark/copy, scales down the hero portrait, and shrinks a giant SVG-masked overlay (500x) so the dark screen retracts into the CASA / VICENTE lockup revealing the scene, then a cream fade-overlay blooms and a serif headline is unveiled with an animated bottom-up gradient wipe.

How it's built

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

scroll pin scrub logo-reveal svg-mask layered-parallax lenis gsap cinematic

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

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 direct imgs, 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; its p is font-size: 0.65rem.
  • .fade-overlay: background-color: #fff; will-change: opacity; start it at opacity: 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 its background).
  • .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():

  1. logoDimensions = logoContainer.getBoundingClientRect(); logoBoundingBox = logoMask.getBBox().
  2. scaleFactor = Math.min(logoDimensions.width / bbox.width, logoDimensions.height / bbox.height) (contain-fit).
  3. Translation centers the scaled path inside the container box, compensating for the bbox origin:
  4. x = logoDimensions.left + (logoDimensions.width − bbox.width × scaleFactor) / 2 − bbox.x × scaleFactor
  5. y = logoDimensions.top + (logoDimensions.height − bbox.height × scaleFactor) / 2 − bbox.y × scaleFactor
  6. logoMask.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-container scale: 1.5 − 0.5 × n — i.e. linear 1.5 → 1.0 (the artwork starts zoomed in and settles).
  • .overlay scale: exponential decay overlayScale = 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 with gsap.set(svgOverlay, { transformOrigin: "50% 25%", scale: overlayScale, force3D: true }) — note the transform origin switches to 50% 25% here so the hole converges on the logo-container position (top 25% of the viewport).
  • .fade-overlay opacity: 0 until progress ≥ 0.25, then min(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 h1 background 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-origin center 0%) while fading 0 → 1.
  • For progress < 0.7, keep h1 at opacity: 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):

  1. 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.
  2. 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).
  3. 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 #111117 background during load to avoid flashes.