Landing Page Reveal — Rolling-Digit Counter Preloader + Exploding Loader Bar + Masked Heading Rise
Goal
Build a full-screen preloader-to-content intro that plays once automatically on page load (~8.5 s total). Three digit columns in the lower-left corner spin upward like a mechanical odometer to count 000 → 100, while a two-segment white bar fills a grey track in the center. When the count lands on 100, the digit columns lift up and vanish behind a clip-path window; then the white loader bar snaps (one half rotates), balloons 40×, rotates and flies off-screen to the bottom-right as a wipe; the black loading screen fades to nothing; and behind it a big centered "Website Content" heading rises up from behind a white revealer edge. Everything is driven by plain standalone gsap.to / gsap.from tweens (no timeline object, no ScrollTrigger) synchronized purely by delay.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use only gsap (npm) — import gsap from "gsap". No GSAP plugins, no smooth-scroll library, no canvas/WebGL. The counter-column setup runs inside a DOMContentLoaded handler; the rest of the tweens run at module top level (they fire as soon as the script loads). The whole thing is a pure load-triggered animation — no scroll, hover, or click.
Layout / HTML
Two sibling roots directly in <body>: .website-content (the page revealed at the end) and .loading-screen (the black overlay on top). Class names are load-bearing — the JS/CSS query them.
<div class="website-content">
<div class="header">
<div class="h1">
<h1>Website</h1>
<h1>Content</h1>
</div>
<div class="header-revealer"></div>
</div>
</div>
<div class="loading-screen">
<div class="loader">
<div class="loader-1 bar"></div>
<div class="loader-2 bar"></div>
</div>
<div class="counter">
<div class="counter-1 digit">
<div class="num">0</div>
<div class="num num1offset1">1</div>
</div>
<div class="counter-2 digit">
<div class="num">0</div>
<div class="num num1offset2">1</div>
<div class="num">2</div><div class="num">3</div><div class="num">4</div>
<div class="num">5</div><div class="num">6</div><div class="num">7</div>
<div class="num">8</div><div class="num">9</div><div class="num">0</div>
</div>
<div class="counter-3 digit">
<div class="num">0</div><div class="num">1</div><div class="num">2</div>
<div class="num">3</div><div class="num">4</div><div class="num">5</div>
<div class="num">6</div><div class="num">7</div><div class="num">8</div>
<div class="num">9</div>
</div>
</div>
</div>
Counter columns are three stacked strips of .num cells (each cell one digit); animating the strip's y upward is what rolls the digits. Their final resting cells must read 1 / 0 / 0 so the counter ends on "100":
.counter-1(hundreds): exactly 2 cells —0,1. The1carries the extra classnum1offset1..counter-2(tens): exactly 11 cells —0,1,2,3,4,5,6,7,8,9,0(one full 0→9 cycle plus a trailing0). The1carriesnum1offset2..counter-3(units): 31 cells total — digits0–9repeated three times then one trailing0. In the original, the HTML holds only the first0–9(10 cells) and JS appends the remaining 21 onDOMContentLoaded:
``js const counter3 = document.querySelector(".counter-3"); for (let i = 0; i < 2; i++) { for (let j = 0; j < 10; j++) { const div = document.createElement("div"); div.className = "num"; div.textContent = j; counter3.appendChild(div); } } const finalDiv = document.createElement("div"); finalDiv.className = "num"; finalDiv.textContent = "0"; counter3.appendChild(finalDiv); ` (You may equivalently hardcode all 31 cells in HTML — the animation only cares that there are 31 and the last is 0`.)
No nav, no footer, no images.
Styling
Font: the CSS declares font-family: "Aeonik_OVERVIEW" on html, body with no fallback and no @font-face — it is a custom face that is not bundled, so it resolves to the browser default. Keep it declared verbatim for faithfulness (do not add a web font). Everything is font-weight: 400.
Global reset: * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100vw; height:100vh }. The page has no background color of its own, so the body is the browser default white — this is what shows through when the black loading screen fades.
Loading screen (the black overlay):
.loading-screen:position:fixed; top:0; left:0; width:100%; height:100%; background:#000; color:#fff; pointer-events:none.
Counter (lower-left odometer):
.counter:position:fixed; left:50px; bottom:50px; display:flex; height:100px; font-size:100px; line-height:102px; font-weight:400; clip-path: polygon(0 0, 100% 0, 100% 100px, 0 100px). The clip-path is the key mask: it clips each column to a single 100px-tall window so only one digit shows at a time; the rest of the strip is hidden above/below..counter-1, .counter-2, .counter-3 { position:relative; top:-15px }(nudges the visible digit up into the window; these are the.digitelements animated later)..num1offset1 { position:relative; right:-25px }and.num1offset2 { position:relative; right:-10px }— the glyph1is narrower than other digits, so these push it right to keep the rolling column visually aligned.
Loader (center progress bar):
.loader:position:absolute; top:50%; left:50%; width:300px; height:50px; transform:translate(-50%,-50%); display:flex; background:rgb(80,80,80)— a 300×50 grey track centered on screen..loader-1:position:relative; background:#fff; width:200px(left white segment)..loader-2:position:relative; background:#fff; width:100px(right white segment)..bar { height:50px }. The two white segments sit side by side inside the flex track and together fill it (200 + 100 = 300).
Website content (revealed heading):
.website-content:position:absolute; width:100%; height:100%; top:0; left:0; display:flex; justify-content:center; align-items:center(centers the header)..header:position:relative; width:max-content; height:max-content..h1 { display:flex }(the two words sit side by side).h1 { font-size:80px }..header .h1 h1:text-align:center; position:relative; top:80px; margin:0 10px; text-transform:uppercase; font-weight:400. Thetop:80pxpushes the text down 80px so it sits below the header box, hidden behind the revealer..header-revealer:position:absolute; top:0; width:100%; height:100%..header-revealer::after:content:""; position:absolute; left:0; top:80px; width:105%; height:110%; background:#fff. This is a solid white curtain whose top edge sits at80px— exactly where the shifted-down text begins — so the words are fully hidden below it. When eachh1slides up by 80px (see effect), it rises above this white edge and is revealed. (The heading is black default text on the white body background.)
GSAP effect (be exact)
No timeline object is used — every tween below is an independent gsap.to()/gsap.from(), scheduled by its own delay. Times given are absolute seconds from page load. All counter tweens live inside DOMContentLoaded; all others run at module top level.
1 — Rolling digit counter, 000 → 100 (t = 0 → 6)
A shared helper translates each column up by (cellCount − 1) × cellHeight, so the last cell of the strip ends inside the 100px clip window:
function animate(counter, duration, delay = 0) {
const numHeight = counter.querySelector(".num").clientHeight; // ≈102px (line-height)
const totalDistance = (counter.querySelectorAll(".num").length - 1) * numHeight;
gsap.to(counter, { y: -totalDistance, duration, delay, ease: "power2.inOut" });
}
animate(document.querySelector(".counter-3"), 5); // units: y ≈ -(30×102), t 0 → 5
animate(document.querySelector(".counter-2"), 6); // tens: y ≈ -(10×102), t 0 → 6
animate(document.querySelector(".counter-1"), 2, 4); // hundreds: y ≈ -(1×102), t 4 → 6
- Units (
.counter-3): rolls through 0→9 three times over 5 s (power2.inOut), the fastest spinning column. - Tens (
.counter-2): rolls 0→9 once over 6 s (power2.inOut), landing back on 0. - Hundreds (
.counter-1): waitsdelay 4, then flips0 → 1over 2 s (ends at t = 6). - All three finish by t ≈ 6, reading 100.
power2.inOutgives every column an eased start and a soft settle.
2 — Loader bar fills the track (t = 0 → 6)
gsap.from(".loader-1", { width: 0, duration: 6, ease: "power2.inOut" }); // t 0 → 6
gsap.from(".loader-2", { width: 0, delay: 1.9, duration: 2, ease: "power2.inOut" }); // t 1.9 → 3.9
- Left segment
.loader-1growswidth 0 → 200pxover the full 6 s (power2.inOut). - Right segment
.loader-2stays collapsed atwidth 0until t = 1.9, then grows0 → 100pxover 2 s. Between them the white progressively fills the grey 300px track in lockstep with the count.
3 — A late loader-2 nudge (t ≈ 6, hidden under the fly-off)
gsap.to(".loader-2", { x: -75, y: 75, duration: 0.5, delay: 6 });
A standalone tween that slides .loader-2 by x:-75, y:75 over 0.5 s. Give it delay: 6 so it fires at t ≈ 6 — the exact moment the whole .loader is being told to drop its grey track, rotate .loader-1, and (at t = 7) balloon 40× and fly off-screen. Because the nudge happens after loader-2 has already finished filling the track and while the bar is exploding away, its offset is never visible.
Why delay: 6 and NOT the original's "<" — read carefully, this is the one thing that breaks blind repros. The real source writes this line as gsap.to(".loader-2", { x:-75, y:75, duration:0.5 }, "<"). That trailing "<" is not ignored and it does not run at t = 0. GSAP appends every bare gsap.to/gsap.from to gsap.globalTimeline and reads the 3rd argument as a position parameter; "<" means "start at the start of the tween that was created immediately before this one." In the original the tween created right before it is the .loader-1 { rotate:90, y:-50, delay:6 } tween, so "<" resolves to t ≈ 6 — identical to delay: 6. This makes the "<" form order-dependent and fragile: if you create this nudge after a different bare tween (e.g. the .loader-2 width fill at delay:1.9), "<" resolves to t ≈ 2 instead, and a stray white box appears below-left of the loader bar during t ≈ 2–6 — a visible defect that is wrong. Using an explicit delay: 6 is behaviour-identical to the original and immune to this ordering trap, so prefer it (or omit the nudge entirely — it has no visible effect either way). If you insist on reproducing the "<" verbatim, you MUST place this line immediately after the .loader-1 rotate tween of section 5 and nowhere else.
Observable ground truth — use this to self-verify your repro: throughout the entire count/loading phase (t ≈ 2–6 s) .loader-2 must stay inside the grey 300px track, sitting flush against .loader-1 so the two white segments fill the track cleanly. There must be no stray white box appearing below or to the left of the bar at any moment before the fly-off. If you see one, your nudge fired early — switch it to delay: 6.
4 — Digit columns lift away (t = 6 → 7)
gsap.to(".digit", {
top: "-150px",
stagger: { amount: 0.25 },
delay: 6,
duration: 1,
ease: "power4.inOut",
});
All three .digit columns animate their CSS top from -15px to -150px — sliding up out of the 100px clip window (so the finished "100" disappears upward). stagger.amount: 0.25 spreads the three column starts across a total of 0.25 s (left column first). power4.inOut, duration 1.
5 — Loader snaps, then explodes off-screen (t = 6 → 8)
gsap.to(".loader", { background: "none", delay: 6, duration: 0.1 }); // t 6 → 6.1
gsap.to(".loader-1", { rotate: 90, y: -50, duration: 0.5, delay: 6 }); // t 6 → 6.5
gsap.to(".loader", { scale: 40, duration: 1, delay: 7, ease: "power2.inOut" }); // t 7 → 8
gsap.to(".loader", { rotate: 45, y: 500, x: 2000, duration: 1, delay: 7, ease: "power2.inOut" }); // t 7 → 8
- t = 6: the grey track
backgroundis removed ("none", near-instant 0.1 s), leaving only the white bars. - t = 6 → 6.5:
.loader-1rotates90°and shiftsy:-50— the left white segment kicks up and turns vertical, breaking the flat bar shape. - t = 7 → 8: the whole
.loaderreceives two simultaneous tweens (different properties, so both apply): it scales up 40× while also rotating45°and translating tox:2000, y:500. Net result: the white bars balloon to fill/sweep the screen and fling off toward the bottom-right — a white wipe transition. Both usepower2.inOut.
6 — Black screen fades out (t = 7.5 → 8)
gsap.to(".loading-screen", { opacity: 0, duration: 0.5, delay: 7.5, ease: "power1.inOut" });
As the loader flings away, the entire black overlay fades opacity 1 → 0 over 0.5 s (power1.inOut), uncovering the white website content beneath.
7 — Heading rises from behind the revealer (t = 7 → 8.5)
gsap.to("h1", 1.5, { // legacy signature: 2nd arg = duration
delay: 7,
y: -80,
ease: "power4.inOut",
stagger: { amount: 0.1 },
});
Both <h1> words ("Website", "Content") slide y: -80 — exactly cancelling their CSS top:80px, so they rise up above the white revealer's top edge and become visible. Duration 1.5 s (legacy gsap.to(target, duration, vars) form), stagger.amount: 0.1 (the two words offset by a hair), power4.inOut, starting at t = 7 as the black screen is vanishing. This is the payoff reveal.
Timeline summary (absolute seconds)
| t (s) | what happens | |-----------|--------------| | 0 → 5 | units column rolls 0→9 ×3 (power2.inOut) | | 0 → 6 | tens column rolls 0→9 once; .loader-1 fills 0→200px | | 1.9 → 3.9 | .loader-2 fills 0→100px | | 4 → 6 | hundreds column flips 0→1 → counter reads 100 | | ~6.1 | grey loader track background:none | | 6 → 6.5 | .loader-1 rotate 90° + y:-50 | | 6 → 6.5 | .loader-2 nudge x:-75, y:75 — invisible, hidden under the fly-off (delay:6, not "<") | | 6 → 7 | .digit columns slide top -15→-150px and vanish (stagger 0.25, power4.inOut) | | 7 → 8 | .loader scale ×40 + rotate 45° + fly to x:2000,y:500 (power2.inOut) | | 7 → 8.5 | both h1 rise y:-80 from behind white revealer (dur 1.5, stagger 0.1, power4.inOut) | | 7.5 → 8 | .loading-screen fades opacity 1→0 |
Total runtime ≈ 8.5 s.
Ease reference
- Counter columns and the loader-bar fills:
power2.inOut. - Loader scale/rotate/fly-off:
power2.inOut. - Digit lift-away and heading rise:
power4.inOut. - Loading-screen fade:
power1.inOut. .loader-1rotate/y(t=6) and the loader-2 nudge (t≈6): default ease (no ease specified →power1.out).
Assets / images
None. This component uses no images, video, canvas, or WebGL — everything is DOM boxes, text, and CSS. The only "graphics" are the grey/white loader rectangles and the numeric text.
Behavior notes
- Autoplay once on load — no scroll, hover, or click triggers, and nothing repeats. The
.loading-screenhaspointer-events:noneso it never blocks the page. - The counter's
clip-path100px window is essential — without it every digit in each column would show at once instead of a single rolling digit. - Reproduce the standalone-tween structure exactly (no
gsap.timeline()): the choreography relies entirely on matchingdelayvalues. Do not copy the loader-2 nudge's"<"position argument blindly — it is agsap.globalTimelineposition param that resolves to the start time of whichever bare tween was created just before it (t ≈ 6 in the original), NOT t = 0. Usedelay: 6on that nudge instead (see section 3); getting this wrong is the one mistake that produces a visible stray white box. - Slow eased-in start: the counter and both loader fills use
power2.inOut, so the first ~1 s has near-zero visible motion before things ramp up. This is expected. A frame diff taken over just the opening second may read as "no animation" — that is the eased start settling in, not a dead effect; sample later frames (t ≈ 2–8) to confirm motion. - Not explicitly responsive — fixed px sizing (100px digits, 300px loader, 80px heading, 50px offsets). It is a desktop-oriented intro; on small screens the elements simply keep their absolute px sizes.
- No
prefers-reduced-motionhandling in the original.
</content> </invoke>