Landing Page Reveal — Revealer Wipe → Image Stack → Flip-to-Corner Intro
Goal
Build a full-screen editorial fashion landing hero with a cinematic load-triggered intro (~8.6 s, plays once). Two white panels (top + bottom) split apart with a custom hop ease to reveal a full-bleed image; a deck of 8 images scales down from 1.5× and fades in one after another so they cascade into place; then GSAP Flip shrinks the final three "main" images from full-screen down to a small stacked cluster of thumbnails in the bottom-left corner. Simultaneously the hero furniture slides in — the logo, nav links, address and a SplitType-split heading all rise up from behind clip-path masks, and a desaturated team image in the bottom-right unmasks upward. Everything is orchestrated by nested GSAP timelines that fire automatically on DOMContentLoaded.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins Flip and CustomEase, and split-type (npm, imported as SplitType) for the line-splitting. No smooth-scroll library — the page does not scroll during the intro; it is a pure load-triggered timeline. Register with gsap.registerPlugin(Flip, CustomEase) and fire the whole sequence on DOMContentLoaded.
Imports:
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { CustomEase } from "gsap/CustomEase";
import SplitType from "split-type";
gsap.registerPlugin(Flip, CustomEase);
Layout / HTML
Semantic structure (class names are load-bearing — the JS/CSS query them):
<div class="container">
<div class="revealers">
<div class="revealer r-1"></div> <!-- top white panel -->
<div class="revealer r-2"></div> <!-- bottom white panel -->
</div>
<div class="images">
<div class="img"><img src="..." alt="" /></div> <!-- 1: first shown -->
<div class="img"><img src="..." alt="" /></div> <!-- 2 -->
<div class="img"><img src="..." alt="" /></div> <!-- 3 -->
<div class="img"><img src="..." alt="" /></div> <!-- 4 -->
<div class="img"><img src="..." alt="" /></div> <!-- 5 -->
<div class="img main"><img src="..." alt="" /></div> <!-- 6: Flip target -->
<div class="img main"><img src="..." alt="" /></div> <!-- 7: Flip target -->
<div class="img main"><img src="..." alt="" /></div> <!-- 8: Flip target -->
</div>
<div class="hero-content">
<div class="site-logo">
<div class="word"><h1>Arc</h1></div>
<div class="word"><h1>Worldwide<sup>©</sup></h1></div>
</div>
<div class="nav">
<div class="nav-item"><p>About</p></div>
<div class="nav-item"><p>Work</p></div>
<div class="nav-item"><p>Journal</p></div>
<div class="nav-item"><p>Contact</p></div>
</div>
<div class="team-img"><img src="..." alt="" /></div> <!-- reuse image 3 -->
<div class="site-info">
<div class="row">
<div class="col"><div class="line"><p>Featured Works</p></div></div>
<div class="col">
<h2>Arc is a contemporary fashion brand redefining elegance with timeless designs and innovative aesthetics.</h2>
</div>
</div>
<div class="row">
<div class="col"></div>
<div class="col">
<div class="address">
<div class="line"><p>Arc Studio</p></div>
<div class="line"><p>Riverstone Building</p></div>
<div class="line"><p>- 28 Orchard Lane</p></div>
<div class="line"><p>N1 4DX</p></div>
</div>
<div class="socials">
<div class="line"><p>SayHi@Arc.com</p></div>
<br />
<div class="line"><p>Instagram</p></div>
<div class="line"><p>LinkedIn</p></div>
</div>
</div>
</div>
</div>
</div>
</div>
Notes:
.imagesholds exactly 8.imgwrappers. The last three (indices 5,6,7) also carry classmain— those are the Flip targets that survive into the corner stack; the first five are.remove()d from the DOM mid-sequence..team-imgreuses the same photo as image 3 (bottom-right, rendered grayscale).- Use "Arc" / "Worldwide" as the neutral placeholder brand. Copyright superscript
©after "Worldwide".
Styling
Fonts: the design calls for "PP Neue Montreal" (body + all h1/h2) and "Apercu Mono Pro" (the small .site-info p labels). These are custom faces — if unavailable, fall back to a clean geometric sans (e.g. Helvetica Neue / Inter) for the display type and a monospace for the small labels. Keep the family names in the CSS with sensible fallbacks.
Palette: dead simple — background #fff, all text #000. The revealer panels are also #fff.
Global:
* { margin:0; padding:0; box-sizing:border-box }.html, body { width:100%; height:100%; font-family:"PP Neue Montreal"; background:#fff }.img { width:100%; height:100%; object-fit:cover }.
Containers:
.container:position:relative; width:100vw; height:100vh; overflow:hidden..revealers:position:fixed; inset:0; width:100vw; height:100vh; display:flex; flex-direction:column; z-index:2. Each.revealer:flex:1; width:100%; background:#fff(so the two panels each fill 50% of the viewport height). Initialclip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(full rectangle — both panels fully opaque and covering everything)..images:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; height:100%; transform-origin:center center; will-change:transform..img:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(1.5); width:100%; height:100%; opacity:0; will-change:transform..img:first-child { opacity:1 }(only the first image is visible at the start; all start zoomed to1.5×).
Stacked (post-Flip) state — these classes are added by the JS, but the CSS must define them:
.images.stacked-container:position:fixed; left:2em; bottom:2em; width:auto; height:auto; display:flex; flex-direction:column-reverse; align-items:flex-start; gap:1em; transform:none; will-change:transform..img.stacked:position:relative; width:150px; height:100px; transform:none; top:auto; left:auto; opacity:1; will-change:transform.
Hero furniture:
.hero-content:position:relative; width:100%; height:100%..site-logo:position:absolute; top:2em; left:2em; display:flex; gap:1em..site-logo h1:color:#000; font-size:5vw; font-weight:500; line-height:1; letter-spacing:-0.01em..site-logo h1 sup:position:absolute; top:-0.125em; font-size:2rem..nav:position:absolute; right:0; width:50%; padding:2em; display:flex; justify-content:flex-end; gap:1em..nav .nav-item p:font-size:16px; font-weight:500..team-img:position:absolute; right:2em; bottom:2em; width:40%; height:50%. Initialclip-path: polygon(0 100%, 100% 100%, 100% 100%, 0% 100%)(collapsed onto the bottom edge → hidden)..team-img img { filter:saturate(0) }(grayscale)..site-info:position:absolute; bottom:2em; left:2em; width:50%; height:50%; display:flex; flex-direction:column; justify-content:space-between..site-info .row:display:flex; gap:2em;.row .col { flex:1 }..site-info h2:font-size:25px; font-weight:500; line-height:1.25..site-info p:text-transform:uppercase; font-family:"Apercu Mono Pro"; font-size:11px; font-weight:500; color:#000; line-height:1.25. The second row's second col isdisplay:flexso.addressand.socialssit side by side (eachflex:1).
Mask / initial hidden states (critical — the reveal depends on these):
.word, .nav-item, .line { clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%) }— each is an overflow-clipping mask window..word h1, .nav-item p, .line p { position:relative; will-change:transform; transform:translateY(100%) }— the text sits one full line below its mask (hidden)..site-info h2 .line span { display:block; transform:translateY(100%) }— the split heading lines also start pushed one line down inside their masks.
GSAP effect (be exact)
Setup
document.addEventListener("DOMContentLoaded", () => {
CustomEase.create("hop", "M0,0 C0.355,0.022 0.448,0.079 0.5,0.5 0.542,0.846 0.615,1 1,1");
CustomEase.create("hop2", "M0,0 C0.078,0.617 0.114,0.716 0.255,0.828 0.373,0.922 0.561,1 1,1");
hop— a slightly "sticky" ease-in-out (hangs near the middle, then snaps to the end). Used for the revealer wipe, the Flip, and the team-image unmask.hop2— an ease-out-ish curve that leaps early then settles. Used for the masked-text rise.
SplitType on the heading, then re-wrap each line in a mask. Split .site-info h2 into lines, then for every produced line replace it with a <div class="line"><span>…</span></div> wrapper (so it picks up the .line clip-path mask and the span { translateY(100%) } init):
const splitH2 = new SplitType(".site-info h2", { types: "lines" });
splitH2.lines.forEach((line) => {
const text = line.textContent;
const wrapper = document.createElement("div");
wrapper.className = "line";
const span = document.createElement("span");
span.textContent = text;
wrapper.appendChild(span);
line.parentNode.replaceChild(wrapper, line);
});
Three timelines:
const mainTl = gsap.timeline();
const revealerTl = gsap.timeline();
const scaleTl = gsap.timeline();
revealerTl — the two white panels split apart (both start together, "<")
revealerTl
.to(".r-1", {
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", // bottom edge collapses UP to the top → wipes up
duration: 1.5, ease: "hop",
})
.to(".r-2", {
clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", // top edge collapses DOWN to the bottom → wipes down
duration: 1.5, ease: "hop",
}, "<"); // simultaneous
The top panel wipes upward and the bottom panel wipes downward at the same time, splitting open like a horizontal shutter over 1.5 s to reveal the image deck behind.
scaleTl — image deck scales in and cascades
scaleTl.to(".img:first-child", { scale: 1, duration: 2, ease: "power4.inOut" });
const images = document.querySelectorAll(".img:not(:first-child)"); // the other 7
images.forEach((img) => {
scaleTl.to(img, {
opacity: 1, scale: 1, duration: 1.25, ease: "power3.out",
}, ">-0.95"); // start 0.95s BEFORE the previous tween ends → heavy overlap
});
- The already-visible first image de-zooms
scale 1.5 → 1over 2 s (power4.inOut). - Each of the other 7 images fades
opacity 0 → 1and de-zooms1.5 → 1over 1.25 s (power3.out), each inserted at">-0.95"(0.95 s before the previous one finishes). Because duration 1.25 − 0.95 = ~0.3 s effective step, the images cascade in quick succession, each layer landing on top of the last.scaleTltotal ≈ 4.1 s.
mainTl — the master sequence
mainTl
.add(revealerTl) // t = 0 .. 1.5
.add(scaleTl, "-=1.25") // inserted at 0.25 → images start scaling while panels still opening
.add(() => { /* Flip, see below */ }) // fires at ~t = 4.35 (end of scaleTl)
.to(".word h1, .nav-item p, .line p, .site-info h2 .line span", {
y: 0, duration: 3, ease: "hop2", stagger: 0.1, delay: 1.25,
})
.to(".team-img", {
clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)",
duration: 2, ease: "hop", delay: -4.75,
});
Placement math (position params / delays resolve to absolute times):
revealerTlat 0 → spans [0, 1.5].scaleTlat"-=1.25"→ inserted at 0.25 → spans [0.25, ~4.35]. So the deck begins de-zooming while the revealer shutter is still opening.- The Flip callback is appended at the end (~4.35 s) — a zero-duration
.add(fn). - The masked-text
.to(...)is appended at ~4.35 withdelay: 1.25→ animates [5.6, 8.6]. - The team-image
.to(...)is appended at the new end (~8.6) withdelay: -4.75→ pulled back to [3.85, 5.85] (overlaps the Flip and the text rise).
Total runtime ≈ 8.6 s.
The Flip callback (the signature move) — full-screen images → corner thumbnail stack
Fires at ~t = 4.35. It captures the current geometry of the three .main images (currently full-bleed and centered), reparents/restyles them into a small bottom-left column, then Flips from the old geometry to the new:
() => {
// 1. Drop the first five images out of the DOM — only the 3 .main survive.
document.querySelectorAll(".img:not(.main)").forEach((img) => img.remove());
// 2. Snapshot the .main images while they're still full-screen.
const state = Flip.getState(".main");
// 3. Restyle the container + images into the stacked-corner layout.
const imagesContainer = document.querySelector(".images");
imagesContainer.classList.add("stacked-container"); // → fixed bottom-left, column-reverse, gap 1em
document.querySelectorAll(".main").forEach((img, i) => {
img.classList.add("stacked"); // → position:relative, 150×100px
img.style.order = i;
gsap.set(".img.stacked", { clearProps: "transform,top,left" });
});
// 4. Animate from the snapshot to the new layout.
return Flip.from(state, {
duration: 2,
ease: "hop",
absolute: true,
stagger: { amount: -0.3 }, // reverse-order stagger, 0.3s total spread
});
}
Visual result: the three overlapping full-screen images simultaneously shrink and fly down to the bottom-left corner, landing as a tidy vertical stack of three 150×100 thumbnails (gap 1em, column-reverse order), the reverse stagger (amount: -0.3) making the last one lead. absolute: true lets them animate freely from their overlapping full-bleed positions. Runs 2 s, independent of mainTl (the returned tween is standalone).
Masked text rise (hop2, t = 5.6 → 8.6)
.to(".word h1, .nav-item p, .line p, .site-info h2 .line span", {
y: 0, duration: 3, ease: "hop2", stagger: 0.1, delay: 1.25,
})
Every masked text piece — the two logo words (Arc, Worldwide©), the four nav links, the "Featured Works" / address / socials lines, and each split heading line — slides from translateY(100%) (parked below, clipped by its .word / .nav-item / .line mask) up to y:0. Duration 3 s, ease hop2, staggered 0.1 s across all pieces in DOM order.
Team image unmask (hop, t = 3.85 → 5.85)
.to(".team-img", {
clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)",
duration: 2, ease: "hop", delay: -4.75,
})
The bottom-right grayscale image reveals from its collapsed bottom edge upward — its two top corners travel from y:100% to y:0%, so the frame grows up from the bottom to fill its box. Duration 2 s, ease hop.
Timeline summary (absolute seconds)
| t (s) | what | |-----------|------| | 0 – 1.5 | revealer panels split apart (r-1 up, r-2 down), hop | | 0.25 – 2.25 | first image de-zooms 1.5→1, power4.inOut | | ~1.3 – 4.35 | other 7 images fade in + de-zoom, ~0.3s cascade, power3.out | | ~4.35 – 6.35 | Flip: 3 main images shrink/fly to bottom-left thumbnail stack, hop, reverse stagger | | 3.85 – 5.85 | team image clip-path unmasks upward, hop | | 5.6 – 8.6 | logo/nav/address/heading lines rise up from masks, hop2, stagger 0.1 |
Ease reference
hop=CustomEase.create("hop", "M0,0 C0.355,0.022 0.448,0.079 0.5,0.5 0.542,0.846 0.615,1 1,1")— revealer wipe, Flip, team-image unmask.hop2=CustomEase.create("hop2", "M0,0 C0.078,0.617 0.114,0.716 0.255,0.828 0.373,0.922 0.561,1 1,1")— masked text rise.- Image deck: first image
power4.inOut; the other sevenpower3.out.
Assets / images
8 editorial fashion photographs, all rendered full-bleed (object-fit: cover), so exact aspect ratios are flexible since they are cover-cropped. Roles:
- Images 1–5 — the hero deck layers that scale in and fade; they are
.remove()d once the Flip fires, so they're only seen briefly stacking up. Image 2 reads best in portrait orientation. Image 3 is also reused (grayscale/desaturated) as the bottom-right.team-img. - Images 6–8 — the three
.mainframes that survive the Flip and become the150×100(≈3:2 landscape) corner thumbnails. Image 7 works well roughly square.
All are moody, high-fashion editorial portraits (single subject, studio or atmospheric backdrop) with a cohesive elegant, minimal look. No brand marks. If you have fewer than eight, repeat images to fill the deck; the three used in the corner stack (6–8) should be distinct so the final cluster reads as three photos.
Behavior notes
- Autoplay once on
DOMContentLoaded; no scroll, hover, or click triggers. The page does not scroll during the intro (.containerisoverflow:hidden, viewport-sized). - The first five
.imgwrappers are permanently removed from the DOM once the Flip fires — do not rely on them existing afterward. - Keep every
will-changehint (transform on.images/.img/.stacked, and on the masked text pieces) — they matter for smooth transform + clip-path animation. - The whole piece is viewport-height (
100vh) full-screen; it is a preloader-style hero, so it is intended to sit above the fold and animate on first paint.