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 bone 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 bone 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
Fonts: Space Grotesk for the display type (the counter, the preloader wordmark, the giant hero name), Inter for body, Space Mono for the small nav labels. All three are free web fonts and must actually be loaded — nothing here relies on a system fallback.
Palette (P1 Canary):
:root {
--color-bone: #f4f4f0; /* the overlay, the nav bar and the giant hero name */
--color-ink: #0a0a0a; /* the counter digits, the wordmark, nav text */
--color-accent: #ffe500; /* canary — used as a surface (chip, underline) with ink on top, never as text */
--color-gray: #8c8c88;
}
--color-bone: #f4f4f0— the overlay background, the nav background, and the colour of the giant hero name.--color-ink: #0a0a0a— the counter digits, the preloader wordmark and the 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-bone); 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-family:"Space Grotesk"; font-size:160px; font-weight:600; letter-spacing:-0.03em; text-align:center; text-transform:uppercase. The 160px is deliberate: the 600-weight Grotesk runs wider than a light face, and at 200px the reveal clip window (.text, 40vw) shaved the wordmark..counter p:line-height:100%..counter p span, .logo p span:position:relative; z-index:-2; color: var(--color-ink)..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-bone); 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:font-family:"Space Mono", monospace; text-decoration:none; text-transform:uppercase; color: var(--color-ink)..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-family:"Space Grotesk"; font-weight:600; font-size:30vw; letter-spacing:-0.03em; color: var(--color-bone); 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) the bone 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 bone overlay fades to transparent, revealing the hero underneath.
- t=4–6: the giant bone 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). A low-key editorial fashion portrait — a model in a white shirt against a dark charcoal wall. It has to be dark, because the giant hero name is bone-coloured and sits directly on it. - 10 preloader images (
loader.jpg, repeated 10×): a portrait (~2:3, ~0.69:1) photograph filling the.img-holder(object-fit:cover). An editorial fashion portrait in raking light, matching the hero. 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 pdrop to100px, and toclamp(48px, 18vw, 90px)on phones, where the centre nav links hide. The animation values are otherwise unchanged.
Images
This component ships with 2 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/landing-page-reveal/hero.jpg
https://motionprompts.dev/c/landing-page-reveal/loader.jpg
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--color-bone,--color-ink,--color-accent,--color-gray. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that means two startLoader() calls, each with its own private currentValue closure, both self-rescheduling through setTimeout and both racing to overwrite the same .counter p's innerHTML on independent random schedules — the big black number visibly stutters between two unrelated climbs instead of counting once up to 100%. Whichever climb lands on 100 first fires its own animateText, running all seven reveal tweens — digits out, wordmark in and back out, overlay fade, hero de-zoom, hero name up, nav down — while the other climb is still ticking; when that second climb finishes, it fires the same seven tweens again, a second time, on top of a hero and nav the first pass already settled. None of this reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — This component starts from two separate triggers, and each is broken in React for a different reason. Both splitTextIntoSpans calls and the two .img-holder img wipe tweens are wrapped in a DOMContentLoaded listener with no readyState guard — by the time a React effect runs, that event has already fired, so this half is dead code the instant it's ported. startLoader() isn't inside that listener at all: it's a bare statement at the bottom of the module, evaluated at import time, before the .counter p node updateCounter immediately writes into even exists. Delete the listener and the standalone call, and fold both bodies into one useEffect with an empty dependency array, in the same order the file already implies — split first, then start the counter. The original only gets away with the reverse-looking file order (the counter-starting call sits below the function that needs the split to have already run) because a module script finishes evaluating — and therefore calls startLoader() — before the browser's own DOMContentLoaded fires; once both are ordinary statements inside one synchronous effect body, that accident of module timing does no work anymore, but keeping the split first still matches what actually executes first once you merge them.
*(2) Element lookups* — .logo p, .hero-copy h1, .img-holder img, .counter p, .overlay, .hero img, nav, .counter p span, .logo p span, and .hero-copy h1 span are ten separate global lookups, each trusting this markup is the page's only copy. Give the component a root ref and scope every one of them to it — either directly (rootRef.current.querySelector(...)) or implicitly, by creating the GSAP calls inside a gsap.context scoped to that ref, which resolves its own selector strings against the scope automatically. This matters most for .counter p: during the StrictMode remount two copies of the markup exist for an instant, and an unscoped lookup can bind the entire counting loop's innerHTML writes to the copy that's on its way out.
*(3) Cleanup* — This component splits cleanly into a part gsap.context sees for free and a part it does not, and the two need different treatment. The image-stack tweens on .img-holder img are created synchronously, in the same tick the effect runs, so a plain gsap.context((self) => { /* the split calls, then both .img-holder img tweens */ }, rootRef) auto-tracks them — ctx.revert() kills them and clears their inline left values whether they've already played out or are still sitting on their own multi-second delay. The seven reveal tweens inside animateText get none of that for free: animateText only runs once updateCounter's self-rescheduling setTimeout chain reaches 100 and then waits out a further pause of its own, so by the time any of those seven gsap.to calls executes, the context's synchronous factory pass finished seconds earlier. Attribute them explicitly by registering a named function inside the factory pass — self.add("revealText", () => { /* the seven reveal tweens */ }) — and invoking it later as ctx.revealText() from inside that callback: the registration itself runs during the factory's own synchronous pass, where only the self parameter is safe to reference, but the function body runs only once called, and any animations it creates still funnel into the context for ctx.revert() to catch.
The counter's timer chain needs its own guard regardless, for the same reason a gsap.ticker.add subscription would: neither a raw setTimeout nor a ticker callback is a tween or a trigger, so nothing about gsap.context knows either one exists. Keep the latest timeout id in a variable the cleanup can reach, and a cancelled flag checked at the top of every tick and again before the pending reveal fires:
useEffect(() => {
let cancelled = false;
let tickId;
const ctx = gsap.context((self) => {
// splitTextIntoSpans(".logo p") and (".hero-copy h1"), scoped to rootRef.current,
// then the two .img-holder img tweens (wipe in, wipe out), unchanged
self.add("revealText", () => {
// the seven reveal tweens: counter digits out, wordmark in, wordmark out,
// overlay fade, hero de-zoom, hero name up, nav down
});
}, rootRef);
let currentValue = 0;
function updateCounter() {
if (cancelled) return;
if (currentValue === 100) {
tickId = setTimeout(() => {
if (cancelled) return;
ctx.revealText();
}, 300);
return;
}
// increment currentValue, re-render ".counter p" as spans, exactly as above
tickId = setTimeout(updateCounter, /* the same randomized gap the original uses */);
}
updateCounter();
return () => {
cancelled = true;
clearTimeout(tickId);
ctx.revert();
};
}, []);
Skip the flag and StrictMode's mount-unmount-mount leaves the first mount's climb running on its own schedule — currentValue lives in a closure private to that one call of updateCounter, so the two mounts never even share a counter — until it eventually reaches 100 and fires its own animateText against a .overlay, .hero img, and nav the second mount is independently animating too.
One thing the cleanup does *not* need: reverting splitTextIntoSpans itself. It rebuilds innerHTML from the element's current innerText every time it runs, and innerText collapses the existing per-character spans back to the same plain string — so a second pass, even one triggered by a StrictMode remount, produces the same flat one-span-per-character markup rather than nesting a fresh split inside the last one. There's no .revert() to call and no flag to guard it with.