Editorial Portfolio Preloader → Stacked Clip-Path Hero Reveal
Goal
Build a full-screen photographer/creative-director landing-page intro. On load, a bottom-right odometer counter made of four stacked digit reels rolls from 000% up to 100% while a thin black progress bar fills, all sitting on a flat chrome-yellow page. When the "load" completes, a stack of 7 full-bleed editorial photos wipe in one-by-one via a staggered clip-path reveal (right edge → left), the whole hero scales up to 1.3, the top nav drops down into place, and a giant surname headline rises letter-by-letter out of a clip mask. Everything plays automatically once on page load — no scroll, no hover, no click. Total run ≈ 12.5 s.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no smooth-scroll library. Import import gsap from "gsap";. The whole thing is a set of independent load-triggered gsap.to / gsap.set tweens (not one master timeline); each is scheduled by an explicit delay. Wrap the logic in a DOMContentLoaded listener. The per-character text split of the headline is done with a small hand-rolled function (wrap each character in a <span>) — NOT the SplitText plugin.
Layout / HTML
Two stacked layers over a .hero section. Class/tag names are load-bearing — the JS queries them.
<section class="hero">
<div class="pre-loader">
<p>Loading</p>
<div class="counter">
<div class="digit-1">
<div class="num">0</div>
<div class="num offset">1</div>
</div>
<div class="digit-2">
<div class="num">0</div><div class="num">1</div><div class="num">2</div>
<div class="num">3</div><div class="num">4</div><div class="num">5</div>
<div class="num">6</div><div class="num">7</div><div class="num">8</div>
<div class="num">9</div><div class="num">0</div>
</div>
<div class="digit-3">
<div class="num">0</div><div class="num">1</div><div class="num">2</div>
<div class="num">3</div><div class="num">4</div><div class="num">5</div>
<div class="num">6</div><div class="num">7</div><div class="num">8</div>
<div class="num">9</div>
</div>
<div class="digit-4">%</div>
</div>
<div class="progress-bar"></div>
</div>
<div class="hero-imgs">
<img src="<photo 1>" alt="" />
<img src="<photo 2>" alt="" />
<img src="<photo 3>" alt="" />
<img src="<photo 4>" alt="" />
<img src="<photo 5>" alt="" />
<img src="<photo 6>" alt="" />
<img src="<photo 7>" alt="" />
</div>
</section>
<div class="website-content">
<nav>
<div class="logo"><p>Logo</p></div>
<div class="site-info"><p>(Photographer, creative director, filmmaker)</p></div>
<div class="menu"><p>Menu</p></div>
</nav>
<div class="header"><h1>Marlow</h1></div>
</div>
The .digit-1 and .digit-2 reels are authored fully in HTML; the .digit-3 reel is authored with only its first 10 nums and then extended by JS (see GSAP setup). <h1> holds a single surname word (use a neutral placeholder like Marlow — an arbitrary 6-letter surname; the character count drives the reveal stagger). Do not use any real client brand.
Styling
Single flat palette:
#ebdc0b— chrome/acid yellow: the full-page background (html, body), the nav text color, and the headline color.#000— black: default text (theLoadinglabel + counter digits sit black-on-yellow) and the progress-bar fill.
Global reset: * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100vw; height:100vh; overflow:hidden; background:#ebdc0b }.
Typography (two display faces; if the exact fonts are unavailable, substitute a bold condensed display serif for the loader/nav and a very thin, light, wide sans for the headline):
- Loader label, counter and nav use family "Timmons NY" (a tall condensed display face).
- Headline
<h1>uses family "PP Neue World", weight 200 (ultra-thin).
Element specifics (initial states matter — the animation depends on them):
img—position:absolute; width:100%; height:100%; object-fit:cover;and the critical initial clip-pathclip-path: polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)(all four points collapsed onto the right edge → the image is an invisible zero-width sliver pinned right)..hero—width:100vw; height:100vh; padding:3em(the 3em padding leaves a yellow border framing the photos)..hero-imgs—position:relative; width:100%; height:100%; overflow:hidden; z-index:0(all 7 images stack absolutely inside it)..pre-loader—position:fixed; top:0; right:0; width:200%; height:100%; padding:2em; display:flex; justify-content:flex-end; align-items:flex-end; gap:0.5em; overflow:hidden; z-index:2. It is anchored to the bottom-right; the label + counter + progress bar sit in a row at the bottom-right corner (the 200% width overflows off-screen left)..pre-loader p—width:max-content; text-transform:uppercase; font-family:"Timmons NY"; font-size:60px; line-height:60px..counter—height:100px; display:flex; font-family:"Timmons NY"; font-size:100px; font-weight:400; line-height:150px; clip-path: polygon(0 0, 100% 0, 100% 100px, 0 100px). The clip-path is a 100px-tall mask window; each.numis 150px tall (line-height), so only one digit shows through the window at a time — this is what turns the reels into an odometer..digit-1, .digit-2, .digit-3, .digit-4—position:relative; top:-15px(optical centering inside the 100px clip window)..offset—position:relative; right:-7.5px(kerning nudge for the narrow "1" glyph)..progress-bar—position:relative; top:-15px; width:0%; height:4px; background:#000(a thin black bar that starts at zero width)..website-content—position:absolute; top:0; left:0; width:100%; height:100%; z-index:1(sits above the images).nav—position:fixed; top:0; width:100%; display:flex; padding:2em.nav > div { flex:1; font-family:"Timmons NY"; font-size:36px; font-weight:lighter; color:#ebdc0b; text-transform:uppercase }..site-info { text-align:center },.menu { text-align:right }→ logo left, credits centered, menu right..header—position:absolute; top:50%; left:50%; transform:translate(-50%,-50%).h1—text-transform:uppercase; font-family:"PP Neue World"; font-size:20vw; font-weight:200; color:#ebdc0b; clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%). The clip-path is a rectangular mask that hides anything pushed below the headline box.h1 span(produced by the JS split) —position:relative; top:400px(every character starts 400px below its resting line, hidden under the h1 clip mask).
GSAP effect (be exhaustive)
Everything runs inside one DOMContentLoaded handler. There is no timeline object — each behavior is an independent gsap.to scheduled by delay. ease strings assume core GSAP eases (power2/3/4.inOut/out).
Setup (before the tweens)
gsap.set("nav", { y: -150 })— park the whole nav 150px above the top edge.- Grab
const digit1 = ".digit-1",digit2 = ".digit-2",digit3 = ".digit-3". - Headline split — hand-rolled: read
.header h1innerText, split into characters, wrap each char in<span>…</span>, join, and set as the h1's innerHTML. (Marlow→ 6 spans. Each span inherits the CSStop:400px.) - Extend the units reel — the
.digit-3reel authored with 10 nums (0–9) is grown by JS: append two more full0–9runs (nested loopi<2,j<10, each a<div class="num">j</div>), then append one final<div class="num">0</div>. Result:.digit-3ends with 31 nums total, and its last num is0.
Final resting readout of the four reels (hundreds/tens/units/%) is 100% (digit-1→1, digit-2→0, digit-3→0, digit-4 is the static %).
A — Odometer counter roll (three reels)
Helper:
function animate(digit, duration, delay = 1) {
const numHeight = digit.querySelector(".num").clientHeight; // = 150px (line-height)
const totalDistance = (digit.querySelectorAll(".num").length - 1) * numHeight;
gsap.to(digit, { y: -totalDistance, duration, delay, ease: "power2.inOut" });
}
Each reel translates up by (numCount − 1) × 150px so its last num lands in the clip window. Calls, in order:
animate(digit3, 5)— units reel (31 nums):y: -4500, duration 5, delay 1 (default),power2.inOut.animate(digit2, 6)— tens reel (11 nums):y: -1500, duration 6, delay 1,power2.inOut.animate(digit1, 2, 5)— hundreds reel (2 nums):y: -150, duration 2, delay 5,power2.inOut.
So the units and tens reels start rolling at t=1 (units settles at t≈6, tens at t≈7); the hundreds reel flips 0→1 between t=5 and t=7. All three land at ≈100% by t≈7.
B — Progress bar (two overlapping tweens on the same element)
gsap.to(".progress-bar", { width: "30%", duration: 2, ease: "power4.inOut", delay: 7 });
gsap.to(".progress-bar", {
width: "100%", opacity: 0, duration: 2, ease: "power3.out", delay: 8.5,
onComplete: () => gsap.set(".pre-loader", { display: "none" }),
});
The bar fills 0% → 30% starting at t=7 (power4.inOut, 2 s), then a second tween (starting at t=8.5, power3.out, 2 s) drives it on to 100% while fading its opacity to 0; on completion the entire .pre-loader is hidden (display:none). (Default GSAP overwrite is off, so the two width tweens briefly co-run around t=8.5–9 — that momentary overlap is intended, the net read is "bar fills and dissolves".)
C — Stacked photo reveal (the star effect)
gsap.to(".hero-imgs > img", {
clipPath: "polygon(100% 0%, 0% 0%, 0% 100%, 100% 100%)",
duration: 1, ease: "power4.inOut", stagger: 0.25, delay: 9,
});
Each image animates its clip-path from the collapsed right-edge sliver polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%) to the full rectangle polygon(100% 0%, 0% 0%, 0% 100%, 100% 100%) — i.e. the two left-hand vertices slide from x=100% to x=0%, so each photo wipes open from the right edge leftward. Duration 1 s each, power4.inOut, stagger 0.25 s across the 7 images (last image starts at t≈10.5), all beginning at delay 9. Because they're stacked absolutely, each new reveal paints over the previous one; the 7th photo is what's left on screen.
D — Hero zoom-in
gsap.to(".hero", { scale: 1.3, duration: 3, ease: "power3.inOut", delay: 9 });
The whole .hero (photo stack) scales 1 → 1.3 over 3 s starting at t=9, so the images push in cinematically as they reveal.
E — Nav drop-in
gsap.to("nav", { y: 0, duration: 1, ease: "power3.out", delay: 11 });
Nav slides down from y:-150 to y:0 over 1 s at t=11.
F — Headline rise (letter by letter)
gsap.to("h1 span", { top: "0px", stagger: 0.1, duration: 1, ease: "power3.out", delay: 11 });
Each character span animates its CSS top from 400px → 0px, power3.out, 1 s, stagger 0.1 s, delay 11. Since <h1> has a rectangular clip-path mask, the letters emerge from below and rise into the box one after another (Marlow → 6 chars, last starts at t≈11.5).
Approximate timeline (seconds from load)
| t | event | |---|-------| | 0 | nav parked at y:-150; reels read 000%; photos are right-edge slivers | | 1 | units reel (A, dur 5) + tens reel (A, dur 6) start rolling | | 5 | hundreds reel (A, dur 2) starts its 0→1 flip | | ~6 | units reel settles on 0 | | ~7 | tens + hundreds settle → 100%; progress bar starts 0→30% (B) | | 8.5 | progress bar 30→100% + fade-out begins (B) | | 9 | photos begin staggered clip-path reveal (C); hero starts scale→1.3 (D) | | ~10.5 | progress bar done → .pre-loader hidden; last photo reveal fires | | 11 | nav drops in (E); headline letters begin rising (F) | | ~12.5 | headline fully risen; hero settled |
Assets / images
7 full-bleed editorial portrait photographs, cover-cropped (object-fit: cover, so aspect ratio is flexible — shot roughly portrait ~2:3). They share a warm, cinematic rust/orange palette: a red-haired model against burnt-orange velvet furniture and sunlit concrete, styled with black heels, a vintage cream rotary phone, crystal-chandelier light refractions, and rust-toned gowns/blouses. Any cohesive set of 7 warm editorial fashion/portrait frames works — they are only ever seen as the sequential clip-path reveal, so ordering matters more than exact content (a strong final frame = the 7th, which stays on screen). No brand marks.
Behavior notes
- Autoplay once on
DOMContentLoaded; no scroll, hover, or click. Whole intro ≈ 12.5 s. - The counter relies on the rendered
clientHeightof a.num(150px), so the.digit-3reel must be extended beforeanimate()runs (same tick is fine). - Responsive (
@media (max-width:900px)):.pre-loaderpadding drops to1em;.counterfont-size →70px;.pre-loader p→font-size:40px; line-height:64px;.offsetnudge →right:-5px. The animation logic is unchanged; the headline stays fluid at20vw. - Keep
body { overflow:hidden }and.hero-imgs { overflow:hidden }so the scaling photos and off-screen loader never spill. - No reduced-motion handling exists in the reference; add one if desired but it is not part of the reference behavior.