Landing Page Reveal — Random-Tick Counter Preloader + Clip-Path Hero Unmask
Goal
Build a full-viewport editorial studio landing hero with a preloader-to-hero reveal that plays automatically once on page load (~7 seconds total). A tiny centered number, masked inside a small clipping window, slides up into view and rapidly ticks 0 → 100 with irregular random jumps. The instant it hits 100 it slides up and out of its window, and that same moment fires the reveal: a scaled-down (0.7), clip-path-collapsed hero opens from its bottom edge upward while scaling up to full size; the dark overlay covering it wipes upward off the top; the bottom hero image strip de-zooms from 2x to 1x; and the giant 38.5vw headline's per-character <span>s rise up from far below into place with a stagger. Everything is driven by chained standalone GSAP tweens (no master timeline), stitched together with onComplete / onStart callbacks, using a CustomEase named hop plus power3/power4 eases.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin CustomEase (import { CustomEase } from "gsap/CustomEase"). No smooth-scroll library — the page does not scroll (.container is overflow: hidden, exactly one viewport). Register the plugin with gsap.registerPlugin(CustomEase) and run the whole sequence inside a DOMContentLoaded listener.
Layout / HTML
Semantic structure (class names are load-bearing — the JS/CSS query them):
<div class="container">
<div class="counter">
<p>0</p>
</div>
<section class="hero">
<div class="overlay"></div>
<nav>
<div class="nav-col">
<div class="nav-items"><a href="#">zayno</a></div>
<div class="nav-items"><p>digital studio</p></div>
</div>
<div class="nav-col">
<div class="nav-items">
<a href="#">work</a><a href="#">studio</a><a href="#">contact</a>
</div>
<div class="nav-items">
<a href="#">twitter</a><a href="#">instagram</a>
</div>
<div class="nav-items"><p>toronto, ca</p></div>
</div>
</nav>
<div class="header"><h1>zayno</h1></div>
<div class="hero-img"><img src="..." alt="" /></div>
</section>
</div>
Notes:
.containeris the single 100vw × 100vh clipping frame (overflow: hidden); everything is absolutely positioned inside it..counterholds one<p>0</p>— this same element is later mutated by the ticker and also GSAP-animated.- Use "zayno" as the neutral placeholder studio name (both the nav logo and the hero headline). Nav text is neutral studio boilerplate:
digital studio,work / studio / contact,twitter / instagram,toronto, ca. .herowraps four stacked layers in DOM order:.overlay(dark cover, top of stack),nav,.header(giant word),.hero-img(bottom image strip).
Styling
Fonts: body declares font-family: "Test Söhne"; the headline declares .header h1 { font-family: "Blanquotey" }. Both are proprietary faces that are NOT bundled and NOT loaded (no @font-face, no <link>) — declare the exact family names but do not attempt to fetch them; the browser falls back to its default (serif) for both, which is how the original renders. Optionally append a generic fallback.
Palette:
- Background
#ebebeb(light warm grey) — the page/body. - Text / overlay
#1a1a1a(near-black).
Global:
* { margin:0; padding:0; box-sizing:border-box }.html, body { width:100%; height:100%; font-family:"Test Söhne"; background:#ebebeb; color:#1a1a1a }.img { width:100%; height:100%; object-fit:cover }.p, a { text-decoration:none; font-size:13px; color:#1a1a1a }.
Key elements and their initial states (the animation depends on these exactly):
.container:width:100vw; height:100vh; overflow:hidden.
.counter:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:40px; height:20px; text-align:center; z-index:0. Initialclip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)(full rectangle — a fixed 40×20px window that clips its content)..counter p:position:relative; display:block; transform:translateY(20px)(pushed down exactly one window-height, so it sits just BELOW the window and is hidden).
.hero:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(0.7); width:100vw; height:100vh; will-change:transform; z-index:1. Initialclip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)(all four corners collapsed onto the BOTTOM edge → zero height → hidden). So the hero starts shrunk to0.7and clipped to nothing.
.overlay:position:absolute; top:0; left:0; width:100%; height:100%; background:#1a1a1a; z-index:2. Initialclip-path: polygon(0 100%, 100% 100%, 100% 0, 0 0)(full rectangle — a solid dark panel covering the entire hero; it is the top-most layer inside.hero).
nav:position:absolute; top:0; left:0; width:100%; padding:1em; display:flex(no z-index → sits below the overlay, revealed when the overlay wipes)..nav-col { flex:1; display:flex }..nav-items { flex:1; display:flex; flex-direction:column }..nav-col:nth-child(2) .nav-items:last-child p { text-align:right }.
.header:position:absolute; width:100%; top:-5%; left:0; z-index:1. Staticclip-path: polygon(0 100%, 100% 100%, 100% 0, 0 0)(full rectangle — this one is NEVER animated; it just masks the overflow of the rising spans)..header h1:font-family:"Blanquotey"; font-size:38.5vw; font-weight:lighter; text-align:center; line-height:100%(one enormous centered word spanning the viewport)..header h1 span:position:relative; display:inline-block; transform:translateY(500px)(each character parked 500px below its final position, hidden well under the.headerclip window).
.hero-img:position:absolute; bottom:0; width:100%; height:35vh; overflow:hidden; z-index:0(a full-width strip pinned to the bottom, 35vh tall)..hero-img img:transform:scale(2)(the image starts zoomed to 2×, cover-cropped).
Z-index stack inside .hero (matters for the reveal to read right): .hero-img (0) & nav (auto) < .header (1) < .overlay (2). The dark overlay covers everything until it wipes up.
GSAP effect (be exact)
Setup
gsap.registerPlugin(CustomEase);
CustomEase.create(
"hop",
"M0,0 C0.29,0 0.348,0.05 0.422,0.134 0.494,0.217 0.484,0.355 0.5,0.5 0.518,0.662 0.515,0.793 0.596,0.876 0.701,0.983 0.72,0.987 1,1 "
);
Note: hop is defined in SVG-path form (not the 4-number cubic-bezier shorthand). Reproduce this exact path string — it is a springy, slightly wavy accelerate-then-settle in-out curve used for the two clip-path animations.
Manual character split (NOT the SplitText plugin): a small helper wraps every character of .header h1 in its own <span>. Take the element's innerText, split(""), map each char to ` <span>${char === " " ? " " : char}</span> (a space becomes two ), join(""), and write it back as innerHTML. Run this on .header h1` before animating. (For the word "zayno" this yields 5 spans, no spaces.)
There is no master timeline — the effect is a chain of independent gsap.to() calls wired together through callbacks. Sequence:
1 — Counter number slides into its window (fires on load, delay: 1)
gsap.to(".counter p", {
y: 0,
duration: 1,
ease: "power3.out",
delay: 1,
onComplete: () => animateCounter(),
});
After a 1s initial delay, the <p> slides from translateY(20px) up to y: 0 over 1s with power3.out, sliding "0" up into the 20px clip window. On complete it launches the ticker.
2 — The random-increment ticker (animateCounter, plain JS, NOT a GSAP tween)
A hand-rolled counter, not a tween:
const el = document.querySelector(".counter p");
let currentValue = 0;
const updateInterval = 300; // ms between ticks
const maxDuration = 2000; // ms total counting window
const endValue = 100;
const startTime = Date.now();
const updateCounter = () => {
const elapsed = Date.now() - startTime;
if (elapsed < maxDuration) {
currentValue = Math.min(currentValue + Math.floor(Math.random() * 30) + 5, endValue);
el.textContent = currentValue;
setTimeout(updateCounter, updateInterval);
} else {
el.textContent = endValue; // snap to 100
setTimeout(() => {
gsap.to(el, {
y: -20,
duration: 1,
ease: "power3.inOut",
onStart: () => revealLandingPage(),
});
}, -500); // negative delay → fires next tick
}
};
updateCounter();
So the number jumps upward by a random 5–34 every 300ms, clamped at 100, for 2000ms (~7 irregular ticks). When the 2s window closes it snaps to 100, then the same <p> tweens y: 0 → -20 (slides up and out the TOP of its window) over 1s power3.inOut, and its onStart triggers the whole page reveal at the same instant.
3 — The reveal (revealLandingPage) — one outer tween whose onStart fires four parallel tweens
const revealLandingPage = () => {
gsap.to(".hero", {
clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)",
duration: 2,
ease: "hop",
onStart: () => {
gsap.to(".hero", {
transform: "translate(-50%, -50%) scale(1)",
duration: 2.25, ease: "power3.inOut", delay: 0.25,
});
gsap.to(".overlay", {
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
duration: 2, delay: 0.5, ease: "hop",
});
gsap.to(".hero-img img", {
transform: "scale(1)",
duration: 2.25, ease: "power3.inOut", delay: 0.25,
});
gsap.to(".header h1 span", {
y: 0,
stagger: 0.1,
duration: 2, ease: "power4.inOut", delay: 0.75,
});
},
});
};
Breaking down each simultaneous tween (all start at reveal time t0, offset by their own delay):
- (a) Hero clip-path opens —
t0, duration 2,hop..heroclip-path animates from the collapsed bottom edgepolygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)to the full rectanglepolygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)— the top two vertices travel fromy:100%up toy:0%, so the hero unmasks from the bottom upward. This is the outer tween; itsonStartfires the other three the same frame.
- (b) Hero scales up —
t0 + 0.25, duration 2.25,power3.inOut..herotransformgoes fromtranslate(-50%,-50%) scale(0.7)to... scale(1)— the whole hero grows from 70% to full size, staying centered, in near-lockstep with the clip-path opening.
- (c) Overlay wipes upward off the top —
t0 + 0.5, duration 2,hop..overlayclip-path animates from the full rectanglepolygon(0 100%, 100% 100%, 100% 0, 0 0)to the top-edge-collapsedpolygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)— the bottom two vertices travel fromy:100%up toy:0%, so the dark panel retracts upward and off, uncovering the nav, headline and image beneath it.
- (d) Hero image de-zooms —
t0 + 0.25, duration 2.25,power3.inOut..hero-img imgtransformgoes fromscale(2)toscale(1)— the bottom image strip un-zooms to its natural framing as the overlay clears it.
- (e) Headline characters rise —
t0 + 0.75, duration 2,power4.inOut, stagger 0.1. Every.header h1 spananimatesy: 500px → 0, left-to-right, each starting 0.1s after the previous, so the giant word climbs into place letter by letter behind the (static) header mask.
Timeline summary (approximate absolute seconds from load)
| t (s) | what | |-------|------| | 1.0–2.0 | counter <p> slides up into its window (y:20→0, power3.out) | | ~2.0–4.0 | random ticker 0 → 100 (jumps of 5–34 every 300ms, over 2000ms) | | ~4.0 | snap to 100 → counter slides out top (y:0→-20, power3.inOut) AND reveal fires (onStart) | | ~4.0–6.0 | hero clip-path opens bottom→full (hop) | | ~4.25–6.5 | hero scales 0.7→1 + hero image de-zooms 2→1 (power3.inOut) | | ~4.5–6.5 | dark overlay wipes upward off the top (hop) | | ~4.75–6.9 | headline spans rise y:500→0, stagger 0.1 (power4.inOut) |
Total runtime ≈ 7s.
Ease reference
hop= the SVG-pathCustomEaseabove (springy in-out) — used for BOTH clip-path animations (hero open + overlay wipe).- Counter slide-in uses
power3.out; counter slide-out usespower3.inOut. - Hero scale + image de-zoom use
power3.inOut. - Headline character rise uses
power4.inOut.
Assets / images
One hero image, used as the bottom full-width strip (.hero-img, height:35vh, object-fit:cover, starting scale(2), de-zooming to 1). It is a moody, warm-toned editorial photograph — low-key, atmospheric, with warm amber/brown tones that sit well against the light-grey page and dark type. Aspect ratio is flexible since it is cover-cropped inside the strip; any dark, warm, editorial full-bleed image works. If unavailable, use any single moody warm-toned photo.
Behavior notes
- Autoplay once on load (
DOMContentLoaded); no scroll, hover, or click triggers. The page never scrolls —.containeris exactly one viewport withoverflow: hidden. - The counter number and its slide/tick logic operate on the same
.counter pelement throughout (slide-in → text-ticker → slide-out); don't split these into separate nodes. - Keep the
will-change: transformhint on.hero— it matters for the smooth simultaneous scale + clip-path. - The
.headerclip-path is static (a mask); only the.overlayand.heroclip-paths animate. - Responsive (
@media max-width: 900px): the first nav column's second<p>becomes right-aligned; the entire second nav column isdisplay:none;.headerre-anchors totop:30%;.hero-imggrows toheight:50vh. The animation itself is unchanged — only layout adapts.