Landing Page Reveal — Stepped-Square Preloader + Clip-Path Unmask
Goal
Build a full-screen editorial landing hero fronted by a cinematic preloader-to-hero reveal that plays automatically once on page load (~5.5 seconds). A black full-screen preloader panel holds two columns of small mono copy and a two-digit counter; the masked copy lines and the counter slide up into view, the counter ticks randomly from 00 → 100, and a centered olive-khaki square scales up from nothing to full-viewport in five discrete stepped increments (each with its own duration/ease so it grows in visible pulses rather than one smooth zoom). Then the whole black preloader wipes upward via an animated clip-path polygon while — in perfect sync — the nav bar, the hero background image, and the hero content caption all slide up from 35svh below into their final positions. One single GSAP timeline drives the whole thing; the counter is a separate randomized JS ticker.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText (the only plugin). No smooth-scroll library — the page does not scroll during the intro; it is a pure load-triggered timeline. Register with gsap.registerPlugin(SplitText) and fire everything on DOMContentLoaded.
Layout / HTML
Semantic structure (class names are load-bearing — the JS/CSS query them):
<div class="preloader">
<div class="preloader-revealer"></div>
<div class="preloader-copy">
<div class="preloader-copy-col">
<p>Handpicked collections shaped by artistry, balancing rare elements with a focus on purity.</p>
</div>
<div class="preloader-copy-col">
<p>Explore timeless essentials built with care, thoughtfully designed to guide you.</p>
</div>
</div>
<div class="preloader-counter">
<p>00</p>
</div>
</div>
<nav>
<div class="nav-logo"><a href="#">Atelier Vale</a></div>
<div class="nav-links">
<a href="#">Collections</a>
<a href="#">New Arrivals</a>
<a href="#">The Atelier</a>
<a href="#">Support</a>
</div>
<div class="nav-cta"><a href="#">Create Account</a></div>
</nav>
<section class="hero">
<div class="hero-img"><img src="..." alt="" /></div>
<div class="hero-content">
<div class="product-name"><p>[ Ember No. 04 ]</p></div>
<div class="product-link"><a href="#">View the Collection</a></div>
</div>
</section>
Notes:
.preloaderis a fixed full-screen panel that sits on top of everything (z-index: 2)..preloader-revealeris the centered square that scales up; the two.preloader-copy-colparagraphs and the.preloader-counter pare the mono copy/number inside it.- Use "Atelier Vale" as the neutral placeholder brand. Keep the copy text as-is (two short editorial paragraphs) and the product caption
[ Ember No. 04 ]/View the Collection. - The
navhas three flex zones: logo (left), links (center, 4 links), CTA (right). .herois a single section with a full-bleed background image and a small centered two-row caption near the bottom.
Styling
Font (Google Fonts): Geist Mono (variable, wght 100..900) is the only web font — import it. Everything is small mono type; there is no giant display headline.
Palette (CSS custom properties):
--base-100: #fff(white — inverted text, hero-name panel bg)--base-200: #eff1eb(pale warm off-white — nav-link chip background)--base-300: #5b553b(dark olive/khaki — default text color, the revealer square, the product-link bar)--base-400: #000(black — preloader background)
Global:
* { margin:0; padding:0; box-sizing:border-box }.img { width:100%; height:100%; object-fit:cover }.a, p { color:var(--base-300); text-decoration:none; text-transform:uppercase; font-family:"Geist Mono"; font-size:0.8rem; font-weight:500; letter-spacing:-0.0125rem; line-height:1; display:inline-block }.
Key elements and their initial states (the animation depends on these):
nav:position:fixed; top:0; left:0; width:100%; display:flex; gap:2rem; padding:2rem; z-index:1; will-change:transform..nav-logo, .nav-cta { flex:1; display:flex };.nav-cta { justify-content:flex-end };.nav-links { flex:2; display:flex; justify-content:center; gap:0.5rem }.nav a:height:max-content; color:var(--base-300); background-color:var(--base-200); padding:0.25rem 0.5rem(small pale chips)..nav-logo ais inverted:color:var(--base-100); background-color:var(--base-300)(white text on olive).
section.hero:position:relative; width:100%; height:100svh; overflow:hidden; will-change:transform..hero-img:position:absolute; top:0; left:0; width:100%; height:100%; will-change:transform(full-bleed image layer)..hero-content:position:absolute; bottom:5rem; left:50%; transform:translateX(-50%); display:flex; flex-direction:column; will-change:transform(small centered two-row caption)..product-name, .product-link:flex:1; width:100%; display:flex; justify-content:center; align-items:center; padding:0.75rem 2.5rem..product-name { background-color:var(--base-100) }(white bar, olive text)..product-link { background-color:var(--base-300) }with.product-link a { color:var(--base-100) }(olive bar, white text).
.preloader:position:fixed; top:0; left:0; width:100%; height:100svh; display:flex; align-items:center; padding:2rem; background-color:var(--base-400); overflow:hidden; z-index:2; will-change:clip-path. Initialclip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(full rectangle covering the screen)..preloader p { color:var(--base-100) }(white text on black)..preloader-revealer:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(0); width:100%; aspect-ratio:1; background-color:var(--base-300); z-index:2; will-change:transform(a viewport-wide square, centered, starting atscale(0)→ invisible)..preloader-copy, .preloader-copy-col, .preloader-counter { flex:1; display:flex }..preloader-counter { justify-content:flex-end }(counter pinned to the right)..preloader-copy p { width:75% }(the two copy paragraphs are constrained so each wraps to ~2 lines)..line { will-change:transform; transform:translateY(100%) }— this class is produced by SplitText below; the CSS pre-hides every split line one line-height below its mask.
Stacking note: the copy columns and the counter are static flex children (auto z-index), while .preloader-revealer is z-index:2, so the growing olive square paints over the copy — as the square swells it engulfs the text.
GSAP effect (be exact)
Setup
gsap.registerPlugin(SplitText);
// Helper: split into LINES, masked, each line wrapped in overflow-hidden and given class "line"
const splitTextIntoLines = (selector, options = {}) =>
SplitText.create(selector, {
type: "lines",
mask: "lines",
linesClass: "line",
...options,
});
splitTextIntoLines(".preloader-copy p"); // both copy paragraphs → masked .line spans
splitTextIntoLines(".preloader-counter p"); // the "00" → a single masked .line span
// Push the whole page content down, hidden below the preloader, ready to slide up:
gsap.set(["nav", ".hero-img", ".hero-content"], { y: "35svh" });
Counter — randomized JS ticker (NOT GSAP)
A standalone function animateCounter(selector, duration = 4.5, delay = 2) drives the two-digit number with setTimeout, called as animateCounter(".preloader-counter p", 4.5, 2):
- Capture
startTime = Date.now()immediately (atDOMContentLoaded),maxDuration = duration * 1000 = 4500ms,currentValue = 0. - After a
delay * 1000 = 2000mstimeout, run a self-schedulingupdateCounter(): elapsed = Date.now() - startTime;progress = elapsed / maxDuration.- While
currentValue < 100 && elapsed < maxDuration: computetarget = Math.floor(progress * 100)and a randomjump = Math.floor(Math.random() * 25) + 5(i.e. 5–29); setcurrentValue = Math.min(currentValue + jump, target, 100); write it asString(currentValue).padStart(2, "0")into the element'stextContent; re-schedule viasetTimeout(updateCounter, 200 + Math.random() * 100)(200–300ms between ticks). - Otherwise (time's up) set
textContent = "100". - Net behavior: the counter is invisible/
00until ~t=2s, then jumps to the teens/20s and climbs with a stuttering, target-clamped, randomized cadence, reaching100at ~t=4.5s. NotetextContentwrites replace the SplitText.linewrapper with plain text — that's expected (the line-reveal below only animates the initial00, then the ticker takes over as plain visible text).
The master timeline
One timeline: const tl = gsap.timeline();. Position params: "<" = align to the start of the previous tween; "-=1" = start 1s before the current timeline end. All scale tweens act on the same .preloader-revealer, chained sequentially (except the first, which is parallel to the line reveal).
- Copy + counter lines slide up (
delay: 1→ starts at t=1):
tl.to([".preloader-copy p .line", ".preloader-counter p .line"], {
y: "0%", duration: 1, stagger: 0.075, ease: "power3.out", delay: 1,
});
Every masked line rises from translateY(100%) (hidden below) to 0%, staggered 0.075s, decelerating (power3.out).
- Revealer square, step 1 (position
"<"→ parallel, starts t=1):
tl.to(".preloader-revealer", { scale: 0.1, duration: 0.75, ease: "power2.out" }, "<");
Square scale 0 → 0.1.
- Revealer step 2 (sequential, t=2):
{ scale: 0.25, duration: 1, ease: "power3.out" }—0.1 → 0.25. - Revealer step 3 (sequential, t=3):
{ scale: 0.5, duration: 0.75, ease: "power3.out" }—0.25 → 0.5. - Revealer step 4 (sequential, t=3.75):
{ scale: 0.75, duration: 0.5, ease: "power2.out" }—0.5 → 0.75. - Revealer step 5 (sequential, t=4.25):
{ scale: 1, duration: 1, ease: "power3.out" }—0.75 → 1(fills the viewport width; ataspect-ratio:1it covers the screen). The five different durations/eases make the square appear to grow in discrete pulses.
- Preloader wipes upward (position
"-=1"→ starts t=4.25, 1s before step 6 ends):
tl.to(".preloader", {
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
duration: 1.25, ease: "power3.out",
}, "-=1");
The clip-path's two bottom corners rise to meet the top (100% → 0% on the Y of the lower points), collapsing the black panel's height to zero at the top — the whole preloader (black bg + olive square + text) wipes up and off the top of the screen.
- Nav + hero slide up into place (position
"<"→ parallel with step 7, t=4.25):
tl.to(["nav", ".hero-img", ".hero-content"], {
y: "0%", duration: 1.25, ease: "power3.out",
}, "<");
The nav bar, the hero background image, and the hero caption all slide from y: 35svh (below) up to 0, revealed exactly as the black preloader recedes upward.
Timeline summary (absolute seconds)
| t (s) | what | |------|------| | 1.0–2.0 | copy + counter masked lines slide up (y 100%→0%, stagger 0.075, power3.out) | | 1.0–1.75 | revealer square scale 0→0.1 (power2.out) | | 2.0–3.0 | revealer 0.1→0.25 (power3.out) | | 2.0–4.5 | counter ticks 00→100 (randomized JS, independent of timeline) | | 3.0–3.75 | revealer 0.25→0.5 (power3.out) | | 3.75–4.25 | revealer 0.5→0.75 (power2.out) | | 4.25–5.25 | revealer 0.75→1 (power3.out) | | 4.25–5.5 | preloader clip-path wipes up (dur 1.25, power3.out) + simultaneously nav/hero-img/hero-content slide y 35svh→0 (dur 1.25, power3.out) |
Total runtime ≈ 5.5s (counter reaches 100 at ~4.5s, ~1s before the panel clears).
Ease reference
power3.out— the line reveal, revealer steps 2/3/5, and both the clip-path wipe and the nav/hero slide.power2.out— revealer steps 1 and 4 only.- No CustomEase, no ScrollTrigger, no lerp/rAF loop (the counter uses
setTimeout, notrequestAnimationFrame).
Assets / images
One full-screen hero background image (landscape, ~3:2), displayed full-bleed (object-fit: cover, fills .hero-img). Aspect ratio is flexible since it is cover-cropped. The real asset is a moody editorial product still-life on a near-black background: an open polished-chrome/silver compact case (round mirror lid raised, pressed powder pan below in a soft nude/peach beige tone) sitting on a dark satin surface, lit by a single soft highlight streak across the black backdrop. Dominant colors are deep black and dark charcoal with bright metallic silver reflections and a warm nude/peach accent from the powder. The dark, high-contrast frame lets the light nav chips read clearly, though it skews cooler/darker than the olive #5b553b type palette. Any dark, softly-lit macro product photograph works if unavailable.
Behavior notes
- Autoplay once on load (
DOMContentLoaded); no scroll, hover, or click triggers. The page does not scroll during the intro. - Uses
100svh(small viewport height) so mobile browser chrome doesn't clip the preloader or hero. - Keep the
will-changehints (clip-pathon.preloader,transformon the revealer, nav, hero layers, and split lines) — they matter for smooth clip-path and transform animation. - Responsive (
@media max-width: 1000px):nav .nav-linksis hidden (display:none);.preloaderand.preloader-copyswitch toflex-direction:column;.preloader-revealerwidens towidth:200%(so the square still covers the taller portrait viewport);.preloader-copy-coland.preloader-counterbecomealign-items:center;.preloader-copy pgoes fullwidth:100%. The animation logic itself is unchanged — only layout adapts.