Landing Page Reveal — Counter Preloader + Clip-Path Hero Unmask
Goal
Build a full-screen editorial landing hero with a cinematic preloader-to-hero reveal that plays automatically once on page load (~8.5 seconds total). A giant number in the lower-left ticks 0 → 100 while it scales up and a thin horizontal progress bar draws itself across the screen. When the count finishes, the number wipes out digit by digit; then a hidden hero background image is unmasked by an animated clip-path polygon — collapsed to a single center point, opening to a small centered rectangle, then expanding to the full viewport — with a custom hop ease, while the image itself de-zooms from 2x to 1x. Masked SplitText finishes the sequence: the huge hero headline slides in character by character from the right, and the nav links and footer labels rise up from behind masks. One single GSAP timeline drives everything.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins CustomEase and SplitText. No smooth-scroll library — the page does not scroll during the intro; it is a pure load-triggered timeline. Register the plugins with gsap.registerPlugin(CustomEase, SplitText) and fire the whole sequence on DOMContentLoaded.
Layout / HTML
Semantic structure (class names are load-bearing — the JS/CSS query them):
<div class="preloader-counter">
<h1>0</h1>
</div>
<nav>
<div class="nav-logo"><a href="#">Canon</a></div>
<div class="nav-links">
<a href="#">Index</a>
<a href="#">Collection</a>
<a href="#">Material</a>
<a href="#">Process</a>
<a href="#">Info</a>
</div>
</nav>
<section class="hero">
<div class="hero-bg">
<img src="..." alt="" />
</div>
<div class="header"><h1>Canon</h1></div>
<div class="hero-footer">
<p>Permanence</p>
<p>Craftsmanship</p>
<p>Expression</p>
</div>
<div class="progress-bar">
<div class="progress"></div>
</div>
</section>
Notes:
.preloader-counteris a fixed overlay element that lives outside.hero; it holds the big counting number and is.remove()d from the DOM at the end.- Use "Canon" as the neutral placeholder brand/name (used both in the nav logo and as the hero headline).
.hero-bgholds a single full-bleed<img>; the.progressdiv is a fill nested inside.progress-bar(two stacked bars).
Styling
Fonts (Google Fonts): DM Sans (variable, ital + opsz + wght) is the only web font. Import it. The big h1s declare font-family: "LK_Ternima", "DM Sans", sans-serif — LK_Ternima is an unavailable custom display face, so in practice everything renders in DM Sans (keep the fallback chain but do not attempt to load the missing font). h1 { line-height: 1 }.
Palette (CSS custom properties):
--base-100: #fff(all text / progress fill / white)--base-200: #3a3a3a(progress-bar track color — dark grey)--base-300: #0f0f0f(page background — near-black)
Global:
* { margin:0; padding:0; box-sizing:border-box }.body { background: var(--base-300); color: var(--base-100) }.img { width:100%; height:100%; object-fit:cover }.a, p { text-decoration:none; color: var(--base-100); font-family:"DM Sans"; font-size:0.85rem; font-weight:500; line-height:1 }.
Key elements and their initial states (the animation depends on these):
.preloader-counter:position:fixed; top:50svh; left:2rem; transform: translateY(-50%) scale(0.25); transform-origin:left bottom; will-change:transform; z-index:2. Itsh1:font-size: clamp(2.5rem, 25vw, 25rem). (It starts tiny at scale0.25, anchored by its bottom-left corner, and grows to full size.)
nav:position:fixed; width:100%; padding:2rem; display:flex; justify-content:space-between; align-items:flex-start; z-index:1..nav-links:display:flex; gap:2rem.
.hero:position:relative; width:100%; height:100svh; overflow:hidden.
.hero-bg:position:absolute; inset:0; width:100%; height:100%; z-index:-1; will-change:clip-path. Initialclip-path: polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)(all four corners collapsed onto the center point → fully hidden).
.hero-bg img:position:absolute; top:50%; left:50%; transform: translate(-50%, -50%) scale(2); will-change:transform(centered, zoomed to2x).
.header:position:absolute; bottom:4rem; width:100%; padding:2rem. Itsh1:font-size: clamp(5rem, 18.5vw, 20rem)(huge, bottom-left).
.hero-footer:position:absolute; bottom:2rem; width:100%; padding:2rem; display:flex; justify-content:space-between; align-items:flex-start(three labels spread across the bottom).
.progress-bar:position:absolute; left:2rem; bottom:6rem; width:calc(100% - 4rem); height:1.5px; background:var(--base-200); transform-origin:left; transform:scaleX(0); will-change:transform; overflow:hidden(the dark track, starts collapsed)..progress-bar .progress:position:absolute; width:100%; height:100%; background:var(--base-100); transform-origin:left; transform:scaleX(0); will-change:transform(the white fill inside the track, also starts collapsed).
Split-piece init states (these classes are produced by SplitText below; the CSS pre-hides them):
.word, .char, .digit { position:relative; will-change:transform }..header h1 .char { transform: translateX(100%) }(each headline char parked one char-width to the right, inside its mask).nav a .word, .hero-footer p .word { transform: translateY(100%) }(each nav/footer word parked one line below, inside its mask).
GSAP effect (be exact)
Setup
gsap.registerPlugin(CustomEase, SplitText);
CustomEase.create("hop", "0.9, 0, 0.1, 1"); // steep symmetric in-out — used for the whole hero unmask
// Helper: split + assign a class + wrap each piece in an overflow-hidden mask
const splitText = (selector, type, className) =>
SplitText.create(selector, {
type: type, // "chars" | "words"
[`${type}Class`]: className, // charsClass / wordsClass = className
mask: type, // mask each char/word so translated pieces are clipped
});
const headerSplit = splitText(".header h1", "chars", "char"); // → .char (masked chars)
const navSplit = splitText("nav a", "words", "word"); // → .word (masked words)
const footerSplit = splitText(".hero-footer p", "words", "word"); // → .word (masked words)
const counterProgress = document.querySelector(".preloader-counter h1");
const counterContainer = document.querySelector(".preloader-counter");
const counter = { value: 0 }; // plain object we tween and read in onUpdate
Everything runs on one timeline: const tl = gsap.timeline();. Position params below are absolute times (numbers) or "<" (align to the previously-added tween's start).
1 — Counter ticks up (t = 0, the timeline's first tween)
tl.to(counter, {
value: 100,
duration: 3,
ease: "power3.out",
onUpdate: () => { counterProgress.textContent = Math.floor(counter.value); },
onComplete: () => { /* digit wipe-out, see below */ },
});
The number counts 0 → 100 over 3s, decelerating (power3.out), written as an integer each frame.
onComplete (fires at t ≈ 3) — digit wipe-out: split the now-"100" number into masked chars (splitText(counterProgress, "chars", "digit")), then:
gsap.to(counterSplit.chars, {
x: "-100%",
duration: 0.75,
ease: "power3.out",
stagger: 0.1,
delay: 1, // waits 1s after the count finishes → starts ~t=4
onComplete: () => counterContainer.remove(), // yank the whole counter out of the DOM
});
Each digit of "100" slides left out of its mask (x: -100%), staggered 0.1s, so the number wipes away character by character; then the entire .preloader-counter element is removed.
2 — Counter scales up (parallel, "<" → t = 0)
tl.to(counterContainer, { scale: 1, duration: 3, ease: "power3.out" }, "<");
The whole counter grows from its CSS scale(0.25) to scale(1) over the same 3s, anchored at its bottom-left (transform-origin: left bottom), so the number swells up from the corner as it counts.
3 — Progress-bar track draws in (parallel, "<" → t = 0)
tl.to(".progress-bar", { scaleX: 1, duration: 3, ease: "power3.out" }, "<");
The dark track (.progress-bar) grows scaleX 0 → 1 from the left edge over the same 3s — a thin line drawing itself across the screen in lockstep with the count.
4 — Hero image unmask, part 1: point → small rectangle (t = 4.5)
tl.to(".hero-bg", {
clipPath: "polygon(35% 35%, 65% 35%, 65% 65%, 35% 65%)",
duration: 1.5,
ease: "hop",
}, 4.5);
The .hero-bg clip-path opens from the collapsed center point to a small centered rectangle (30%×30% of the viewport) — the image first appears as a small window in the middle.
5 — Image de-zoom, part 1 (parallel, "<" → t = 4.5)
tl.to(".hero-bg img", { scale: 1.5, duration: 1.5, ease: "hop" }, "<");
Simultaneously the image scales from 2 → 1.5.
6, 7, 8 — Full unmask + de-zoom finish + progress fill (all at t = 6)
tl.to(".hero-bg", {
clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
duration: 2, ease: "hop",
}, 6);
tl.to(".hero-bg img", { scale: 1, duration: 2, ease: "hop" }, 6);
tl.to(".progress", { scaleX: 1, duration: 2, ease: "hop" }, 6);
.hero-bgclip-path expands from the small rectangle out to the full viewport (0%,0% … 100%,100%) over 2s — the image blooms open to full-bleed..hero-bg imgfinishes de-zooming1.5 → 1over the same 2s, so the image settles to its natural framing as it fills the frame..progress(the white fill inside the track) sweepsscaleX 0 → 1from the left over the same 2s — a white line racing across the already-drawn dark track.
9 — Hero headline slides in char by char (t = 7)
tl.to(".header h1 .char", {
x: "0%",
duration: 1,
ease: "power4.out",
stagger: 0.075,
}, 7);
Each headline character slides from translateX(100%) (parked right, hidden by its mask) into place, left to right, staggered 0.075s, with a snappy power4.out.
10, 11 — Nav + footer words rise up (both at t = 7.5)
tl.to("nav a .word", { y: "0%", duration: 1, ease: "power4.out", stagger: 0.075 }, 7.5);
tl.to(".hero-footer p .word", { y: "0%", duration: 1, ease: "power4.out", stagger: 0.075 }, 7.5);
All nav-link words and all footer-label words slide up from translateY(100%) (below, hidden by their masks) into view, staggered 0.075s, power4.out. Both start together at t = 7.5.
Timeline summary (absolute seconds)
| t (s) | what | |------|------| | 0–3 | counter 0→100 (power3.out), counter scales 0.25→1, progress track draws scaleX 0→1 | | ~3 | count done → onComplete splits digits | | ~4–5 | digits wipe left (x:-100%, stagger 0.1) then .preloader-counter removed | | 4.5–6 | hero clip-path point→small rect + image 2→1.5 (hop) | | 6–8 | hero clip-path small-rect→full + image 1.5→1 + white progress fill scaleX 0→1 (hop) | | 7–8 | headline chars slide in from right (stagger 0.075, power4.out) | | 7.5–8.5 | nav + footer words rise up (stagger 0.075, power4.out) |
Total runtime ≈ 8.5s.
Ease reference
hop=CustomEase.create("hop", "0.9, 0, 0.1, 1")— steep symmetric in-out used for the entire hero clip-path unmask and image de-zoom.- Counter, counter-scale, progress-track, and the digit wipe use
power3.out. - All SplitText slide-ins (headline chars, nav words, footer words) use
power4.out.
Assets / images
One hero background image (hero.jpg), used as the full-bleed backdrop (object-fit: cover, centered, initially scale(2)). It is the single subject of the clip-path reveal and the 2x → 1x de-zoom.
The real asset is a landscape (~3:2), classical Baroque oil painting — a mythological figure scene of two nude figures in soft chiaroscuro: a reclining, sleeping figure on draped white linen at the lower left, and a seated winged figure leaning toward them at the right, with faint classical architecture and a distant sky in the background. Dominant colors are warm and low-key: creamy flesh tones and off-white drapery lit against deep browns and shadowed reds, with a muted blue-grey sky in the upper right. The overall feel is dim, painterly, and old-master. Its warm mid-tones and dark edges let the near-black #0f0f0f page and white type sit well against it (the cover-crop centers on the figures). Aspect ratio is flexible since it is cover-cropped; if unavailable, any dark, warm-toned painterly or editorial full-bleed image works.
Behavior notes
- Autoplay once on load (
DOMContentLoaded); no scroll, hover, or click triggers. The page does not scroll during the intro. - The
.preloader-counterelement is permanently.remove()d from the DOM once its digits wipe out — do not rely on it existing afterward. - Uses
100svh(small viewport height) so mobile browser chrome doesn't clip the hero. - Keep the
will-changehints (clip-pathon.hero-bg,transformon the image, counter, progress bars, and split pieces) — they matter for smooth clip-path and transform animation. - Responsive (
@media max-width: 1000px):.nav-linksbecomes a right-aligned vertical column (flex-direction:column; align-items:flex-end; gap:0.5rem);.headerre-anchors to vertical center (bottom:unset; top:50svh; transform:translateY(-50%), centered) and itsh1drops tofont-size:4rem. The animation itself is unchanged — only layout adapts.