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:
div.overlay— full-screen fixed black overlay with three flex columns:div.projectscontainingdiv.projects-headerwith two<p>:ProjectandDirector. (Rows are appended by JS.)div.loadercontainingh1.logo-line-1with textNovaandh1.logo-line-2with textVice(stacked two-line logo).div.locationscontainingdiv.locations-headerwith one<p>:Location. (Rows appended by JS.)div.image-grid— threediv.grid-row, each with threediv.imgwrappers, each wrapping an<img>. The middle cell of the middle row additionally has classhero-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).nav— three flex sections:div.linkswith two anchorsIndex,Work;div.nav-logowith one anchor containingNova<br />Vice; anotherdiv.linkswith anchorsAbout,Contact.div.banner-img.banner-img-1anddiv.banner-img.banner-img-2, each wrapping an<img>(use image 7 and image 16 of the pool).div.intro-copywith two<h3>:Creative SolutionsandImpactful Results.div.titlewith 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; anchorscolor: #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) eachflex: 1; display flex; flex-direction column; justify-content center; gap 1em..loadercenters items withgap: 0..locationscenters items;.locations-headerand.location-itemarewidth: 50%..loader h1:font-size 2.5rem; text-align center, and the gradient-fill trick that lets GSAP "fill up" the text by animatingbackground-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 getflex: 1(two equal columns)..project-itemand.location-itemstartcolor: #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). Globalimg { width 100%; height 100%; object-fit: cover }.nav:position fixed; width 100vw; padding 1em; display flex; gap 2em, its three childrenflex: 1;.linksspreads anchors withjustify-content: space-around;.nav-logocentered, its anchorfont-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 animatesleftandrotate)..intro-copy:position absolute; top 45%; transform translateY(-50%); width 100%; padding 0 8em; display flex; justify-content space-between; align-items center.h3atfont-size 1.5rem..title:position absolute; bottom 10%; left 50%; transform translateX(-50%).h1atfont-size 3.5rem..intro-copy h3and.title h1:font-weight 500; color #000; position relative;andclip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)so the split words can slide up from below the mask. Their.wordspans (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;.titlebecomes full-width, centered,bottom 20%,h12.5rem;.image-gridgrows towidth 75%with0.5emgaps (rowswidth 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): nav → y: "-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)
.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. ItsonCompletefires an independentgsap.to(".logo-line-2", { backgroundPosition: "0% 0%", color: "#fff", duration: 1, ease: "none" })so line 2 fills right after line 1.[".projects-header", ".project-item"]→{ opacity: 1, duration: 0.15, stagger: 0.075, delay: 1 }— the project list types in row by row.- At the same position (
"<"):[".locations-header", ".location-item"]→ same{ opacity: 1, duration: 0.15, stagger: 0.075 }. .project-item→{ color: "#fff", duration: 0.15, stagger: 0.075 }— rows flash from gray to white in sequence; at"<"the same for.location-item.[".projects-header", ".project-item"]→{ opacity: 0, duration: 0.15, stagger: 0.075 }— lists stagger back out; at"<"the same for the locations..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)
- 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. ItsonStartschedules, viasetTimeout(..., 1000): startImageRotation()(see below), andgsap.to(".loader", { opacity: 0, duration: 0.3 })(the logo fades out as the shuffle begins).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..hero-img→{ y: -50, duration: 1, ease: "hop" }— the hero cell nudges up 50px..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. ItsonStartfires three parallel tweens:.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)..banner-img→{ scale: 1, delay: 0.5, duration: 0.5 }(both banners pop in from scale 0 at screen center).nav→{ y: "0%", duration: 1, ease: "hop", delay: 0.25 }(nav drops in).- 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)
- Title words (
titleHeading.words) →{ y: "0%", duration: 1, stagger: 0.1, delay: 9.5, ease: "power3.out" }— words slide up from110%into view behind the h1's clip mask, one every 0.1s. The 9.5s delay lands this right after the hero zoom finishes. - 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.