All components

Lusion Landing Page Reveal

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Full-screen preloader-to-content reveal that plays once on load. Three digit columns spin upward with gsap.to (power2.inOut) to count toward 100 while a two-segment loader bar fills, then at ~6s the digits lift away and the loader rotates, scales 40x and flies off-screen (power2.inOut); the black loading screen fades out and the 'Website Content' heading slides up from behind a white revealer using a staggered gsap.to on the h1 elements.

How it's built

Categorypreloader
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

preloader loader counter reveal gsap stagger clip-path intro minimal

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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. The 1 carries the extra class num1offset1.
  • .counter-2 (tens): exactly 11 cells — 0,1,2,3,4,5,6,7,8,9,0 (one full 0→9 cycle plus a trailing 0). The 1 carries num1offset2.
  • .counter-3 (units): 31 cells total — digits 0–9 repeated three times then one trailing 0. In the original, the HTML holds only the first 0–9 (10 cells) and JS appends the remaining 21 on DOMContentLoaded:

``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 .digit elements animated later).
  • .num1offset1 { position:relative; right:-25px } and .num1offset2 { position:relative; right:-10px } — the glyph 1 is 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. The top:80px pushes 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 at 80px — exactly where the shifted-down text begins — so the words are fully hidden below it. When each h1 slides 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): waits delay 4, then flips 0 → 1 over 2 s (ends at t = 6).
  • All three finish by t ≈ 6, reading 100. power2.inOut gives 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-1 grows width 0 → 200px over the full 6 s (power2.inOut).
  • Right segment .loader-2 stays collapsed at width 0 until t = 1.9, then grows 0 → 100px over 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 background is removed ("none", near-instant 0.1 s), leaving only the white bars.
  • t = 6 → 6.5: .loader-1 rotates 90° and shifts y:-50 — the left white segment kicks up and turns vertical, breaking the flat bar shape.
  • t = 7 → 8: the whole .loader receives two simultaneous tweens (different properties, so both apply): it scales up 40× while also rotating 45° and translating to x: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 use power2.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-1 rotate/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-screen has pointer-events:none so it never blocks the page.
  • The counter's clip-path 100px 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 matching delay values. Do not copy the loader-2 nudge's "<" position argument blindly — it is a gsap.globalTimeline position param that resolves to the start time of whichever bare tween was created just before it (t ≈ 6 in the original), NOT t = 0. Use delay: 6 on 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-motion handling in the original.

</content> </invoke>