All components

Nite Riot Landing Page Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

Autoplay landing-page intro: a black overlay reveals a gradient-fill logo and staggered project/location lists, then a centered 3x3 image grid clips open and rapidly cycles through random fashion photos. The outer cells clip away and the central hero image scales up (clip-path zoom) while two banner images pop and rotate outward, the nav drops in, and masked SplitType word reveals push in the intro copy and title. Runs on DOMContentLoaded via three parallel GSAP timelines with a custom 'hop' CustomEase.

How it's built

Categorypreloader
Techgsap, split-type
GSAP pluginsCustomEase
Complexitypage
Performance costmedium
Mobile-safeyes

reveal preloader clip-path gsap splittype customease editorial image-grid landing

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

Landing Page Reveal — Grid Shuffle to Hero Zoom Intro

Goal

Build an autoplay landing-page intro (runs once on page load, no scroll): a full-screen black overlay reveals a gradient-fill logo and staggered project/location lists, then a centered 3×3 image grid clips open and rapidly shuffles through random editorial photos. The outer cells clip shut again, the center "hero" cell scales up 4× into a large framed hero image, two banner images pop in and rotate outward to its sides, the nav drops in from the top, and masked word-by-word text reveals push in the intro copy and title.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase, and split-type (npm package, SplitType class — NOT GSAP's SplitText). No smooth-scroll library needed (the page never scrolls).

Layout / HTML

Single page, <body> containing in this order:

  1. div.overlay — full-screen fixed black overlay with three flex columns:
  2. div.projects containing div.projects-header with two <p>: Project and Director. (Rows are appended by JS.)
  3. div.loader containing h1.logo-line-1 with text Nova and h1.logo-line-2 with text Vice (stacked two-line logo).
  4. div.locations containing div.locations-header with one <p>: Location. (Rows appended by JS.)
  5. div.image-grid — three div.grid-row, each with three div.img wrappers, each wrapping an <img>. The middle cell of the middle row additionally has class hero-img (i.e. div.img.hero-img). Fill the 9 <img> srcs with the first 9 images of the pool (image 5 in the hero cell).
  6. nav — three flex sections: div.links with two anchors Index, Work; div.nav-logo with one anchor containing Nova<br />Vice; another div.links with anchors About, Contact.
  7. div.banner-img.banner-img-1 and div.banner-img.banner-img-2, each wrapping an <img> (use image 7 and image 16 of the pool).
  8. div.intro-copy with two <h3>: Creative Solutions and Impactful Results.
  9. div.title with one <h1>: Crafting bold experiences.

Also create a small data module projects.js exporting projectsData: an array of ~16 objects { name, director, location } with fictional film-production entries (e.g. { name: "Lunar Eclipse", director: "Amelia Crawford", location: "Toronto, ON" }; some directors styled like "Sophia // Chen", some locations like "Elevation Studios - Denver").

On DOMContentLoaded, JS appends to .projects one div.project-item per entry (two <p>: name, director) and to .locations one div.location-item per entry (one <p>: location).

Styling

  • Reset * { margin:0; padding:0; box-sizing:border-box }. Body background #e3e3db, body font a neue-grotesque sans ("PP Neue Montreal", sans-serif).
  • All <p> and <a>: uppercase, monospace ("Akkurat Mono", monospace), font-size: 0.7rem; anchors color: #000, no underline.
  • Display type (.loader h1, .nav-logo a, .intro-copy h3, .title h1): condensed poster font ("Druk", sans-serif), italic, uppercase, line-height: 0.9.
  • .overlay: position: fixed; inset from top-left; width 100vw; height 100svh; padding 2em; background #000; color #fff; display flex; gap 2em; overflow hidden. Its three children (.projects, .loader, .locations) each flex: 1; display flex; flex-direction column; justify-content center; gap 1em. .loader centers items with gap: 0. .locations centers items; .locations-header and .location-item are width: 50%.
  • .loader h1: font-size 2.5rem; text-align center, and the gradient-fill trick that lets GSAP "fill up" the text by animating background-position:

`` -webkit-text-fill-color: transparent; background-clip: text; background-image: linear-gradient(0deg, #3a3a3a, #3a3a3a 50%, #fff 0); background-size: 100% 200%; background-position: 0% 100%; color: #3a3a3a; ``

  • .projects-header, .project-item, .locations-header, .location-item: display flex; gap 2em; opacity 0 (hidden until animated). Children of the projects rows get flex: 1 (two equal columns). .project-item and .location-item start color: #4f4f4f (dim gray, later flashed to white).
  • .image-grid: position fixed; top 50%; left 50%; transform translate(-50%, -50%); width 30%; aspect-ratio 1; display flex; flex-direction column; gap 1em; z-index 2. Each .grid-row: width 100%; display flex; gap 1em. Each .img: position relative; flex 1; aspect-ratio 1; and the crucial initial clip: clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%) (collapsed to the top edge — invisible). Global img { width 100%; height 100%; object-fit: cover }.
  • nav: position fixed; width 100vw; padding 1em; display flex; gap 2em, its three children flex: 1; .links spreads anchors with justify-content: space-around; .nav-logo centered, its anchor font-size 1.75rem; font-weight bolder.
  • .banner-img: position absolute; top 45%; left 50%; transform translate(-50%, -50%) scale(0); width 20%; aspect-ratio 4/5 (hidden at scale 0, both stacked at center; GSAP later animates left and rotate).
  • .intro-copy: position absolute; top 45%; transform translateY(-50%); width 100%; padding 0 8em; display flex; justify-content space-between; align-items center. h3 at font-size 1.5rem.
  • .title: position absolute; bottom 10%; left 50%; transform translateX(-50%). h1 at font-size 3.5rem.
  • .intro-copy h3 and .title h1: font-weight 500; color #000; position relative; and clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%) so the split words can slide up from below the mask. Their .word spans (created by SplitType): display inline-block; position relative; will-change transform; margin-right 0.1rem.
  • Media query max-width: 900px: hide .projects, .locations, .intro-copy, .banner-img; absolutely center .loader; .title becomes full-width, centered, bottom 20%, h1 2.5rem; .image-grid grows to width 75% with 0.5em gaps (rows width 95%; justify-content space-around).

GSAP effect (exhaustive)

Everything runs on DOMContentLoaded. Register CustomEase and create the signature ease used almost everywhere:

CustomEase.create("hop", "0.9, 0, 0.1, 1");

Split text with SplitType (words only): new SplitType(".intro-copy h3", { types: "words", absolute: false }) and the same for ".title h1". Keep references — you animate splitInstance.words.

Element sets: gridImages = all .img (9); heroImage = .img.hero-img; images = the 8 grid cells excluding the hero.

Image pool: an array of 35 image URLs (img1 … img35). Helper getRandomImageSet() shuffles a copy of the pool (sort(() => 0.5 - Math.random())) and returns the first 9.

Initial states (gsap.set): navy: "-125%"; all intro-copy words and all title words → y: "110%" (below their clip masks).

Three timelines, all created at once so they run in parallel from t=0:

1) overlayTimeline (the black loader screen)
  1. .logo-line-1{ backgroundPosition: "0% 0%", color: "#fff", duration: 1, ease: "none", delay: 0.5 } — the gradient fill wipes the first logo line from dark gray to white, bottom to top. Its onComplete fires an independent gsap.to(".logo-line-2", { backgroundPosition: "0% 0%", color: "#fff", duration: 1, ease: "none" }) so line 2 fills right after line 1.
  2. [".projects-header", ".project-item"]{ opacity: 1, duration: 0.15, stagger: 0.075, delay: 1 } — the project list types in row by row.
  3. At the same position ("<"): [".locations-header", ".location-item"] → same { opacity: 1, duration: 0.15, stagger: 0.075 }.
  4. .project-item{ color: "#fff", duration: 0.15, stagger: 0.075 } — rows flash from gray to white in sequence; at "<" the same for .location-item.
  5. [".projects-header", ".project-item"]{ opacity: 0, duration: 0.15, stagger: 0.075 } — lists stagger back out; at "<" the same for the locations.
  6. .overlay{ opacity: 0, duration: 0.5, delay: 1.5 } — the whole black overlay fades away (the loader logo stays visible longer because the overlay fade is delayed; the logo itself is faded separately below).
2) imagesTimeline (grid reveal → shuffle → hero zoom)
  1. All .img{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 1, delay: 2.5, stagger: 0.05, ease: "hop" } — each cell wipes open top-to-bottom. Its onStart schedules, via setTimeout(..., 1000):
  2. startImageRotation() (see below), and
  3. gsap.to(".loader", { opacity: 0, duration: 0.3 }) (the logo fades out as the shuffle begins).
  4. images (the 8 non-hero cells) → { clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", duration: 1, delay: 2.5, stagger: 0.05, ease: "hop" } — outer cells wipe shut again (collapse back to the top edge), leaving only the hero cell visible.
  5. .hero-img{ y: -50, duration: 1, ease: "hop" } — the hero cell nudges up 50px.
  6. .hero-img{ scale: 4, clipPath: "polygon(20% 10%, 80% 10%, 80% 90%, 20% 90%)", duration: 1.5, ease: "hop" } — the center cell blows up 4× while its clip contracts to an inset frame (20%/10% margins), producing the "zoom into hero" feel. Its onStart fires three parallel tweens:
  7. .hero-img img{ scale: 1, duration: 1.5, ease: "hop" } (the inner image was pre-scaled to 2 during the last shuffle cycle, so it settles down as the wrapper scales up — a counter-zoom).
  8. .banner-img{ scale: 1, delay: 0.5, duration: 0.5 } (both banners pop in from scale 0 at screen center).
  9. nav{ y: "0%", duration: 1, ease: "hop", delay: 0.25 } (nav drops in).
  10. At "<": .banner-img-1{ left: "40%", rotate: -20, duration: 1.5, delay: 0.5, ease: "hop" } and .banner-img-2{ left: "60%", rotate: 20, duration: 1.5, ease: "hop" } — the two banner cards slide out from behind the hero and tilt outward like fanned polaroids.

startImageRotation() — the rapid shuffle: loop cycle from 0 to 19 (20 cycles). For each cycle schedule a zero-duration tween (gsap.to({}, { duration: 0, delay: cycle * 0.15, onComplete })) whose callback assigns a fresh getRandomImageSet() to the 9 grid <img> elements — so all 9 cells swap images every 150ms for 3 seconds. On the LAST cycle only, the hero cell instead gets its final fixed hero image (image 5 of the pool) and gsap.set(".hero-img img", { scale: 2 }) pre-zooms the inner image so step 4 above can counter-animate it back to 1.

3) textTimeline (masked word reveals)
  1. Title words (titleHeading.words) → { y: "0%", duration: 1, stagger: 0.1, delay: 9.5, ease: "power3.out" } — words slide up from 110% into view behind the h1's clip mask, one every 0.1s. The 9.5s delay lands this right after the hero zoom finishes.
  2. At "<": intro-copy words (introCopy.words) → { y: "0%", duration: 1, stagger: 0.1, delay: 0.25, ease: "power3.out" } — the two side headings follow 0.25s later.

Total choreography lasts ~11s from load.

Assets / images

A pool of 35 editorial fashion portraits (moody studio photography: dramatic close-up faces, silhouettes on saturated backgrounds, avant-garde styling). All are used as square crops via object-fit: cover, so any aspect works, but roughly square-to-portrait sources look best:

  • Images 1–9: initial grid cells (image 5 sits in the center hero cell).
  • Image 5: also the final hero image the zoom locks onto.
  • Images 7 and 16: also the two banner images (displayed at 4:5).
  • The whole pool of 35 feeds the random shuffle.

Reference them in an array like ` Array.from({ length: 35 }, (_, i) => ./images/img${i + 1}.jpeg) ` (adjust the path to wherever the images live).

Behavior notes

  • Pure intro animation: no ScrollTrigger, no user input; everything autoplays once per load.
  • Under 900px wide, the side lists, intro copy and banners are hidden; only the logo fill, grid reveal/shuffle, hero zoom, nav and title reveal run.
  • The overlay stays in the DOM after fading (opacity 0); that's fine for a demo.
  • Brand text is the fictional "Nova / Vice"; keep all names/locations fictional.