Landing Page Reveal — Counter Preloader + Sliding Image Stack + Masked Hero Unveil
Goal
Build a full-viewport editorial landing hero fronted by a cinematic preloader that plays automatically once on page load (~8 seconds total). A cream overlay covers the screen; inside it a giant 0 → 100% counter ticks up in randomized jumps while, below it, a stack of ten portrait images wipes across the frame — each image slides in from the left and, moments later, slides out to the right, producing a rapid slideshow-wipe. When the counter hits 100%, the digits slide up out of a mask, a giant masked wordmark slides up into the same slot and then slides up out again, the whole cream overlay fades away, the hero background image de-zooms from 2x to 1x, the giant hero name rises character-by-character out of its clip mask, and the top nav bar drops down into place. All motion is plain gsap.to tweens driven by power4.out / power3.inOut eases — no timeline object, no plugins.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no SplitText (text is split into <span>s by a tiny hand-written helper), no smooth-scroll library (the page does not scroll during the intro; it is a pure load-triggered sequence). Import with import gsap from "gsap";. Two independent trigger mechanisms run in parallel (details below): the image-stack tweens fire on fixed delay timers from load, while the counter/logo/overlay/hero/nav tweens are chained after a randomized counting loop completes.
Layout / HTML
Semantic structure (class names are load-bearing — the JS/CSS query them):
<nav>
<div class="nav-logo"><a href="#">kudos</a></div>
<div class="menu"><p>Menu</p></div>
<div class="shop">
<a href="#">Shop</a>
<a href="#">Cart (0)</a>
</div>
</nav>
<div class="hero">
<img src="/hero.jpg" alt="" />
</div>
<div class="hero-copy">
<h1>kudos</h1>
</div>
<div class="overlay">
<div class="overlay-content">
<div class="images">
<div class="img-holder">
<!-- exactly 10 identical <img> -->
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
<img src="/loader.jpg" alt="" />
</div>
</div>
<div class="text">
<div class="counter"><p>100%</p></div>
<div class="logo"><p>kudos</p></div>
</div>
</div>
</div>
Notes:
- Use "kudos" (lowercase) as the neutral placeholder brand/name — it appears in the nav logo, as the preloader wordmark (
.logo p), and as the giant hero headline (.hero-copy h1). - The
.img-holderholds exactly 10<img>elements pointing at the same portrait image. .overlayis a fixed full-screen cover withz-index:1000that sits above everything during the intro; it is faded toopacity:0at the end but is not removed from the DOM..counter pinitial text is100%(the JS overwrites it every counting tick).
Styling
Font (READ CAREFULLY — this is the #1 fidelity trap in this component):
- The CSS declares
html, body { width:100%; height:100%; font-family:"Manrope" }, but the original never actually loads Manrope — there is no<link>to Google Fonts, no@font-face, and no@importanywhere in the component. Because the only named family is unavailable and no generic fallback follows it, every browser falls back to its default serif (Times New Roman / a Didone-style display serif). That serif fallback is the real, rendered look you must reproduce — it is most visible on the star element. - So the giant preloader wordmark ("kudos"), the big counter digits, and the small nav text all render as a display serif: pronounced thick/thin stroke contrast, bracketed serifs, a Times-New-Roman-at-display-size / Didone-adjacent character — not a thin, even-weight sans.
- Do NOT import Manrope, and do NOT swap in any sans-serif (Inter, Helvetica, Arial, etc.). Importing Manrope (or any sans) makes the giant wordmark and counter render thin and even-stroked, which visibly breaks the look of the hero element and is exactly the mistake to avoid here.
- To reproduce faithfully: keep the literal
font-family:"Manrope"declaration in the CSS (so the code matches the original) and load no font. For a deterministic serif across browsers you may append the generic keyword —font-family:"Manrope", serif— which lands on the same Times/serif fallback; if you instead add your own@font-face, pick a display serif with strong thick/thin contrast and serifs (Times New Roman, a Didone, Playfair-style) so it matches the original render. Never resolve the fallback to a sans-serif.
Palette:
--color-accent: #e6e0d8(warm cream/oat — the overlay background, the nav background, and the color of the giant hero name)#000000(black — the counter digits and preloader wordmark, and nav text)
Global:
* { margin:0; padding:0; box-sizing:border-box }.img { width:100%; height:100%; object-fit:cover }.
Key elements and their initial states (the animation depends on these exact values):
.overlay:position:fixed; top:0; left:0; width:100vw; height:100vh; background:var(--color-accent); display:flex; justify-content:center; align-items:center; z-index:1000..overlay-content:width:40%..images:position:relative; height:550px..img-holder:position:relative; width:80%; height:100%; margin:0 auto; z-index:2; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)(a full rectangle used purely as an overflow mask — the off-screen images are clipped by it)..img-holder img:position:absolute; top:0; left:-110%(each image starts one-and-a-bit widths off to the left, hidden by the clip-path mask)..text:position:relative; margin:1em 0; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)(again a rectangular mask clipping the counter/logo spans that slide above and below it)..counter, .logo p:font-size:200px; text-align:center; text-transform:uppercase(renders in the default serif fallback described above — a display serif with thick/thin contrast, not a sans)..counter p:line-height:100%..counter p span, .logo p span:position:relative; z-index:-2; color:#000000..logo:position:absolute; top:0; left:50%; transform:translateX(-50%)(overlaps the counter in the same masked slot)..logo p:line-height:100%..logo p span:position:relative; top:200px(each wordmark char parked 200px below its natural line, hidden below the.textmask).nav:position:fixed; top:-200px; width:100%; padding:1em; background-color:var(--color-accent); display:flex; align-items:center; z-index:1(parked 200px above the top edge, out of view).nav > div { flex:1 }.nav a, nav p:text-decoration:none; text-transform:uppercase; font-size:15px; color:#000; font-weight:500..menu { display:flex; justify-content:center }..shop { display:flex; justify-content:flex-end; gap:2em }..hero:width:100vw; height:100vh..hero img:transform: scale(2)(starts zoomed to2x; de-zooms to1x)..hero-copy:position:absolute; top:35%; left:50%; transform:translate(-50%,-50%); text-transform:uppercase; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)(rectangular mask over the giant name)..hero-copy h1:font-weight:400; font-size:30vw; color:var(--color-accent); line-height:100%..hero-copy h1 span:position:relative; top:30vw(each char parked ~one line-height below, hidden inside the.hero-copymask).
GSAP effect (be exact)
Text-split helper (not the SplitText plugin)
A hand-written function wraps every character of an element in a <span>:
function splitTextIntoSpans(selector) {
const el = document.querySelector(selector);
if (!el) return;
el.innerHTML = el.innerText.split("").map((c) => `<span>${c}</span>`).join("");
}
On DOMContentLoaded, call it on .logo p and .hero-copy h1 so the preloader wordmark and the hero name each become a row of per-char spans (which the CSS above has already parked below their masks via top:200px / top:30vw).
Clock A — sliding image stack (fixed delay timers from load)
Registered inside the same DOMContentLoaded handler. Two plain tweens on the ten identical images:
A1 — images wipe IN (starts at delay: 4):
gsap.to(".img-holder img", {
left: 0, // from CSS left:-110% → left:0 (fills the holder)
stagger: 0.1, // image 1 → 10, 0.1s apart
ease: "power4.out",
duration: 1.5,
delay: 4,
});
Each image slides from off-left into the frame, one every 0.1s; because they stack in the same absolutely-positioned holder, the later images cover the earlier ones — a rapid left-to-right slideshow wipe. Span of this tween: ~4.0s → ~6.4s.
A2 — images wipe OUT (starts at delay: 7):
gsap.to(".img-holder img", {
left: "110%", // from left:0 → off to the right
stagger: -0.1, // NEGATIVE stagger: last image first, back to first
ease: "power4.out",
duration: 1.5,
delay: 7,
});
The whole stack slides out to the right; the negative stagger reverses the order (image 10 leaves first). Span: ~7.0s → ~9.4s.
Clock B — randomized counter → chained reveal tweens
A startLoader() function runs immediately (called at module top level, before the DOMContentLoaded handler fires). It counts up in random jumps, then, once it reaches exactly 100, fires the reveal.
B0 — the counting loop (updateCounter, self-scheduling via setTimeout):
let currentValue = 0;
function updateCounter() {
if (currentValue === 100) { animateText(); return; }
currentValue += Math.floor(Math.random() * 10) + 1; // jump by 1..10
currentValue = Math.min(currentValue, 100);
// re-render digits as spans + a "%" span, e.g. <span>7</span><span>3</span><span>%</span>
document.querySelector(".counter p").innerHTML =
currentValue.toString().split("").map((c) => `<span>${c}</span>`).join("") + "<span>%</span>";
setTimeout(updateCounter, Math.floor(Math.random() * 200) + 100); // 100..300ms between ticks
}
updateCounter();
The big black number climbs from 0% to 100% in irregular jumps, each digit re-wrapped in its own span every frame. With avg jump ~5.5 and avg delay ~200ms it lands on 100 at roughly 3–3.5s — so it finishes right as the image stack (Clock A) is mid-wipe. (The two clocks are independent; the counter's landing time is random and is tuned only by these ranges.)
B1 — the reveal (animateText, called when the count hits 100). It waits setTimeout(..., 300) and then fires seven independent gsap.to tweens. Treat t = 0 as the moment these tweens are created (≈ count-finish + 0.3s). All use ease:"power3.inOut".
// 1) counter digits slide UP out of the mask
gsap.to(".counter p span", { top: "-400px", stagger: 0.1, ease: "power3.inOut", duration: 1 }); // t=0
// 2) wordmark chars slide UP INTO the mask (top:200px → 0)
gsap.to(".logo p span", { top: "0", stagger: 0.1, ease: "power3.inOut", duration: 1 }); // t=0
// 3) wordmark chars slide UP out of the mask (0 → -400px)
gsap.to(".logo p span", { top: "-400px", stagger: 0.1, ease: "power3.inOut", duration: 1, delay: 3 });// t=3
// 4) cream overlay fades away (opacity 1 → 0; NOT removed from DOM)
gsap.to(".overlay", { opacity: 0, ease: "power3.inOut", duration: 1, delay: 4 }); // t=4
// 5) hero image de-zooms scale 2 → 1
gsap.to(".hero img", { scale: 1, ease: "power3.inOut", duration: 2, delay: 3.5 });// t=3.5
// 6) giant hero name rises char-by-char (top:30vw → 0)
gsap.to(".hero-copy h1 span",{ top: "0", stagger: 0.1, ease: "power3.inOut", duration: 2, delay: 4 }); // t=4
// 7) nav bar drops down (top:-200px → 0)
gsap.to("nav", { top: "0", ease: "power3.inOut", duration: 2, delay: 4 }); // t=4
Sequence within Clock B, in words:
- t=0–1: as the counter reaches 100, its digits slide straight up and out of the
.textmask while, in the same slot, the "kudos" wordmark rises up into view (a swap: number out, name in). - t=3–4: the wordmark slides up and out of the mask (clears the slot).
- t=3.5–5.5: the hero background image de-zooms from
2xto1x. - t=4–5: the cream overlay fades to transparent, revealing the hero underneath.
- t=4–6: the giant cream hero name rises character-by-character out of its clip mask (
top:30vw → 0, staggered 0.1). - t=4–6: the top nav bar slides down from above into place.
Timeline summary (approximate absolute seconds, counter landing ≈ 3.3s)
| t (s) | what | |------|------| | 0–3.3 | counter jumps 0%→100% (random), black digits re-rendered each tick | | ~4.0–6.4 | image stack wipes IN, left→right (left:-110%→0, stagger 0.1, power4.out, 1.5s) | | ~3.6 | count done → 300ms pause → reveal tweens created | | ~3.6–4.6 | digits slide up out (-400px) + wordmark rises in (200px→0) | | ~6.6–7.6 | wordmark slides up out (0→-400px) | | ~7.1–9.1 | hero image de-zooms 2→1 | | ~7.6–8.6 | overlay fades opacity 1→0 | | ~7.6–9.6 | hero name rises char-by-char (30vw→0, stagger 0.1) + nav drops (-200px→0) | | ~7.0–9.4 | image stack wipes OUT to the right (0→110%, negative stagger) |
Total runtime ≈ 8 seconds (preview waits ~8.5s). Because the counter is randomized, exact overlaps drift a little each load — that variability is intentional. The fully-revealed final state (overlay gone, hero de-zoomed, giant hero name up, nav dropped in) only settles at the very end of the sequence (~9.6s); a faithful side-by-side should be captured near the end so both renders show the same phase rather than one mid-reveal.
Ease / value reference
- Image stack in/out:
ease:"power4.out",duration:1.5,stagger:0.1(in) /-0.1(out). - Every reveal tween (digits, wordmark, overlay, hero image, hero name, nav):
ease:"power3.inOut". - Reveal durations: counter/wordmark =
1, hero image / hero name / nav =2, overlay =1. - Reveal staggers (all char groups):
0.1. - The masks are CSS
clip-pathrectangles on.img-holder,.text, and.hero-copy— nothing masks viaoverflow; the animatedleft/topoffsets slide content past those clip edges.
Assets / images
- 1 hero image (
hero.jpg): a full-bleed landscape (~16:9 / ~1.9:1) photograph used as the whole-viewport hero background (object-fit:cover, initiallyscale(2), de-zooming to1). Warm, naturally-lit editorial lifestyle scene — e.g. two people laughing together at a laptop in a cozy sunlit home/office, soft warm neutrals. Any warm-toned, bright lifestyle/editorial full-bleed image works. - 10 preloader images (
loader.jpg, repeated 10×): a portrait (~2:3, ~0.69:1) photograph filling the.img-holder(object-fit:cover). Warm, naturally-lit lifestyle portrait matching the hero's palette — e.g. a person in a bright, plant-filled home workspace. The ten can be the same image (identical, as in the original) or a small matched set; because they overlap and wipe fast, a single repeated portrait reads as a clean recurring wipe.
No client brands anywhere — use "kudos" as the neutral wordmark.
Behavior notes
- Autoplays once on load; no scroll, hover, or click triggers. The page does not scroll during the intro.
startLoader()runs at module top level; thesplitTextIntoSpanscalls and the image-stack tweens run in theDOMContentLoadedhandler (which fires after the module executes, so the wordmark spans exist beforeanimateTextneeds them). - The
.overlaystays in the DOM atopacity:0after the reveal (it is faded, not removed). Give the hero content its natural stacking so the transparent overlay does not block interaction if you extend the page. - No reduced-motion or responsive re-timing in the original — a
prefers-reduced-motionguard that skips straight to the final state (overlay hidden, heroscale:1, name/nav in place) is a reasonable add. - Responsive (
@media max-width: 900px):.overlay-contentwidens to75%;.counter, .logo pfont-size drops from200pxto100px. The animation values are otherwise unchanged.