Image-Grid Assembly Intro → 6× Zoom Hero Reveal
Goal
Build a self-playing, full-viewport intro/preloader for a creative portfolio. On load, five vertical columns of images assemble themselves from off-screen — odd columns slide up from below, even columns' tiles drop in from above, each column's tiles cascading in with a slow power4.inOut stagger. The moment the grid has locked together, the entire image grid scales up 6× (a big zoom-into-the-wall move) while, on the layer above it, the nav links, a masked hero title, a slide counter and a strip of footer thumbnails all slide up into their clip-path frames, and two small "+" icons pop from scale 0 → 1. It is one continuous GSAP timeline that plays exactly once on page load. The star effect is the staggered multi-column grid assembly followed by the synchronized 6× grid zoom + masked content reveal.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no smooth-scroll library, no scroll/hover/click interaction at all. The whole thing is a single load-triggered gsap.timeline(). Import:
import gsap from "gsap";
The two "+" glyphs in the hero are rendered with the Ionicons web component (<ion-icon name="add-sharp">), loaded via its ESM script from a CDN. If you'd rather not add Ionicons, substitute any inline element containing a "+" mark — the animation only needs an element it can scale; just keep the same selectors/initial scale(0).
Layout / HTML
Two stacked full-viewport layers. The image grid is a position: fixed background layer; the content (nav / hero / footer) sits above it at z-index: 2.
<div class="container">
<div class="col c-1">
<div class="item"><img src="<img 1>" alt="" /></div>
<div class="item"><img src="<img 2>" alt="" /></div>
<div class="item"><img src="<img 3>" alt="" /></div>
<div class="item"><img src="<img 4>" alt="" /></div>
<div class="item"><img src="<img 5>" alt="" /></div>
</div>
<div class="col c-2"> …five .item/img, images 6–10… </div>
<div class="col c-3"> …five .item/img, images 11–15… </div>
<div class="col c-4"> …five .item/img, REUSE images 1–5… </div>
<div class="col c-5"> …five .item/img, REUSE images 6–10… </div>
</div>
<div class="content">
<nav>
<div class="nav-item"><a href="#" id="active">Work</a></div>
<div class="nav-item"><a href="#">About</a></div>
</nav>
<div class="hero">
<div class="icon"><ion-icon name="add-sharp"></ion-icon></div>
<div class="title"><p>The Regeneration Site</p></div>
<div class="icon-2"><ion-icon name="add-sharp"></ion-icon></div>
</div>
<footer>
<div class="preview">
<!-- seven small thumbnails drawn from the set (e.g. images 1,2,3,4,5,6,8) -->
<img src="…" alt="" /> … ×7
</div>
<div class="slide-num"><p>1 — 3</p></div>
</footer>
</div>
.container→ the 5-column flex grid (background layer).- Each
.col(.c-1….c-5) → a flex column holding exactly five.items; each.itemwraps one<img>. .content→ nav + hero + footer, above the grid.- Keep all copy neutral/fictional: nav "Work" (active) / "About", hero title "The Regeneration Site", slide counter "1 — 3". No real brand or client names anywhere.
Styling
Page shell
html, body:width: 100vw; height: 100vh; overflow: hidden;background#141414,font-family: "Neue Montreal"(any clean neutral sans-serif is fine as a fallback). The whole component is a fixed, non-scrolling screen.img:width: 100%; height: 100%; object-fit: cover;
Grid (.container / .col / .item)
.container:position: fixed; width: 100%; height: 100%; display: flex; gap: 1em;(5 equal columns side by side). Defaulttransform-origin= center (the 6× zoom scales from the center)..col:position: relative; flex: 1; display: flex; flex-direction: column; gap: 1em;(five equal-height tiles stacked)..item:position: relative; flex: 1; overflow: hidden; background: gainsboro;(a light placeholder shows behind any missing image;overflow: hiddenclips the image).
Content layer
.content:position: relative; width: 100%; height: 100%; z-index: 2;(renders above the fixed grid).nav:position: fixed; width: 100%; padding: 2.5em; display: flex; justify-content: center; align-items: center; gap: 3em;.nav-item:clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);(a full-rectangle mask over its own box)..nav-item a:position: relative; top: 20px; text-decoration: none; color: #fff; opacity: 0.35;— links start 20px below their frame, so the clip-path hides them..nav-item a#active:opacity: 1;(only the first link is fully lit)..hero:position: absolute; width: 95%; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; align-items: center; color: #fff;.icon/.icon-2:flex: 0.5; padding: 0 5em; font-size: 30px; opacity: 0.5;(.icon-2istext-align: right). Their glyphs start hidden:.icon ion-icon, .icon-2 ion-icon { transform: scale(0); }..title:flex: 2; text-align: center; font-size: 40px; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);(masked)..title p:position: relative; top: 50px;— the title starts 50px below its frame, clipped out of view.footer:position: absolute; bottom: 0; width: 100%;.preview:position: absolute; bottom: 2em; right: 2em; display: flex; gap: 0.3em; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);(masked)..preview img:position: relative; top: 100px; width: 80px; height: 50px;— the thumbnail strip starts 100px below, clipped..slide-num:margin: 3em 0; text-align: center; color: #fff; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);(masked)..slide-num p:position: relative; top: 30px;— starts 30px below, clipped.
Off-screen start positions of the grid (load-bearing — this is what the timeline animates FROM):
/* odd columns: the whole column is pushed one viewport DOWN … */
.c-1, .c-3, .c-5 { top: 100%; }
/* … and their tiles are ALSO pushed one viewport down */
.c-1 .item, .c-3 .item, .c-5 .item { top: 100%; }
/* even columns: their tiles are pushed one viewport UP (columns themselves stay at top:0) */
.c-2 .item, .c-4 .item { top: -100%; }
Because .item/.col are position: relative, a top of 100% / -100% resolves against the column height (≈ full viewport), so every off-screen tile is a full screen-height away. overflow: hidden on body clips them until they slide in.
Responsive @media (max-width: 900px): .title font-size → 30px; .icon/.icon-2 → padding: 0 1em; font-size: 16px;; .preview img → 60px × 40px; .slide-num → left-aligned (text-align: left; padding-left: 2em; margin-bottom: 2.5em). Nothing about the animation changes.
GSAP effect (exhaustive)
One timeline, created at module top, no delay: const tl = gsap.timeline({ delay: 0 });. Every tween below is ease: "power4.inOut" and duration: 3 except where noted. Position parameters ("-=n") are relative to the current end of the timeline. Here is the exact build order and the absolute start times it produces:
- Columns rise into place.
``js tl.to(".col", { top: "0", duration: 3, ease: "power4.inOut" }); ` Animates all five columns' top → 0. In practice only the odd columns (.c-1/.c-3/.c-5, which started at top: 100%) visibly slide up a full viewport; the even columns were already at top: 0`. Runs t = 0 → 3s.
- Column 1 tiles cascade up (top → bottom).
``js tl.to(".c-1 .item", { top: "0", stagger: 0.25, duration: 3, ease: "power4.inOut" }, "-=2"); ` The five .c-1 tiles slide top: 100% → 0 with a positive 0.25s stagger (first tile first, going down the column). Position "-=2"` → starts at t = 1s.
- Column 2 tiles cascade down (bottom → top).
``js tl.to(".c-2 .item", { top: "0", stagger: -0.25, duration: 3, ease: "power4.inOut" }, "-=4"); ` The five .c-2 tiles slide top: -100% → 0 (dropping in from above) with a negative 0.25s stagger (stagger: -0.25 reverses the order — last tile first, cascading upward). "-=4"` → starts at t = 1s.
- Column 3 tiles — same as column 1:
stagger: 0.25,top: 100%→0,"-=4"→ starts t = 1s. - Column 4 tiles — same as column 2:
stagger: -0.25,top: -100%→0,"-=4"→ starts t = 1s. - Column 5 tiles — same as column 1:
stagger: 0.25,top: 100%→0,"-=4"→ starts t = 1s.
Net: at t ≈ 1s all five columns' tiles begin sliding into their frames at once — odd columns rising from below, even columns dropping from above, each column self-cascading over 3s. The grid is fully assembled by ≈ t = 5s.
- The whole grid zooms 6×.
``js tl.to(".container", { scale: 6, duration: 4, ease: "power4.inOut" }, "-=2"); ` .container scales 1 → 6 from its center over 4s, "-=2"` → starts at t = 3s (so the zoom is already underway while the last tiles are still settling). The grid rushes toward the viewer and blows past the edges, uncovering the dark background and the content layer.
- Content reveals up through the clip-path masks.
``js tl.to(".nav-item a, .title p, .slide-num p, .preview img", { top: 0, stagger: 0.075, duration: 1, ease: "power3.out" }, "-=1.5"); ` All masked content elements animate their top → 0 (nav links from 20px, title from 50px, slide counter from 30px, each preview thumb from 100px), sliding up into their clip-path frames with a 0.075s stagger, duration: 1, ease: "power3.out". "-=1.5"` → starts at t = 5.5s, overlapping the tail of the zoom.
- The two "+" icons pop in.
``js tl.to(".icon ion-icon, .icon-2 ion-icon", { scale: 1, stagger: 0.05, ease: "power3.out" }, "-=1"); ` The two hero "+" glyphs scale 0 → 1 (no duration set → GSAP default 0.5s), stagger: 0.05, ease: "power3.out". "-=1"` → starts at t ≈ 6.25s.
Timeline summary: total run ≈ 7.25s, plays automatically once on load, never repeats. Trigger = page load only.
Assets / images
15 unique portrait/tall images (object-fit: cover, roughly 2:3–3:4 crops), plus reuse: columns 4 and 5 repeat columns 1 and 2's images, and the footer .preview strip shows seven small 80×50 thumbnails drawn from the same set. It is a curated, high-art editorial + 3D-render mix sharing a moody, cinematic, elegant mood rather than one literal subject — no logos or brand marks. A representative set:
- a cinematic dark render of a tiny lone figure walking toward a bright light between two angled monolith walls (teal/cream);
- a close-up of a racing/space helmet with a molten-gold reflective visor on lime green;
- a sci-fi spacesuited figure before a giant hazy sphere in a golden foggy sunset;
- a moody low-key portrait glimpsed through a rectangular cut-out (lower face, cigarette, blue nails);
- a silhouette pressing against frosted glass with warm orange backlight;
- a dramatic low-key studio portrait of a blonde woman, a shaft of light across her face;
- a 3D chrome robotic head in a mosaic-tiled conical hat and mirrored shades on a teal gradient;
- a minimal 3D golden lucky-cat figurine in a red circular niche on light grey;
- a conceptual portrait of a suited figure whose face is draped in translucent pale-blue tulle;
- a hazy golden-orange city skyline at sunset;
- a cool-toned portrait of a woman with wet slicked hair over her face, one red-nailed arm raised;
- a green-tinted 3D faceless muscular bust with soft bokeh;
- a vintage botanical illustration of red roses on pure black;
- a fashion portrait in oversized futuristic wraparound black visor sunglasses on amber-brown;
- a warm sunset shot of a woman on a paddleboard on calm water.
Any cohesive set of 15 moody editorial/cinematic portrait images works — the exact subjects don't matter, only the tall crop and dark, curated art direction.
Behavior notes
- Load-triggered only. No scroll, no ScrollTrigger, no hover, no click, no loops — the intro auto-plays once and holds on the final revealed state.
- The screen is fixed and non-scrolling (
overflow: hiddenonhtml/body); all off-screen positioning uses percentagetop, so it scales fluidly across viewports. - Reduced-motion is not handled in the original — the animation always plays.
- Preview/verification: the full timeline takes ~7.25s, so allow ≥ ~12s before capturing the settled end state.