All components

O'Shane Howard Landing Page

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

Open live demo ↗ Raw prompt (.md)

What it does

On load, an animated numeric counter (three stacked digit reels tweened with gsap.to and power2.inOut) counts up to 100% behind a filling progress bar, then a stack of hero images each wipe in via a staggered clip-path polygon reveal while the hero scales up, the nav drops in, and the 'Howard' headline rises letter-by-letter with a staggered per-span reveal.

How it's built

Categorypreloader
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

preloader counter clip-path reveal gsap landing-page stagger editorial loader

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

Editorial Portfolio Preloader → Stacked Clip-Path Hero Reveal

Goal

Build a full-screen photographer/creative-director landing-page intro. On load, a bottom-right odometer counter made of four stacked digit reels rolls from 000% up to 100% while a thin black progress bar fills, all sitting on a flat chrome-yellow page. When the "load" completes, a stack of 7 full-bleed editorial photos wipe in one-by-one via a staggered clip-path reveal (right edge → left), the whole hero scales up to 1.3, the top nav drops down into place, and a giant surname headline rises letter-by-letter out of a clip mask. Everything plays automatically once on page load — no scroll, no hover, no click. Total run ≈ 12.5 s.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no smooth-scroll library. Import import gsap from "gsap";. The whole thing is a set of independent load-triggered gsap.to / gsap.set tweens (not one master timeline); each is scheduled by an explicit delay. Wrap the logic in a DOMContentLoaded listener. The per-character text split of the headline is done with a small hand-rolled function (wrap each character in a <span>) — NOT the SplitText plugin.

Layout / HTML

Two stacked layers over a .hero section. Class/tag names are load-bearing — the JS queries them.

<section class="hero">
  <div class="pre-loader">
    <p>Loading</p>

    <div class="counter">
      <div class="digit-1">
        <div class="num">0</div>
        <div class="num offset">1</div>
      </div>
      <div class="digit-2">
        <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 class="num">0</div>
      </div>
      <div class="digit-3">
        <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 class="digit-4">%</div>
    </div>

    <div class="progress-bar"></div>
  </div>

  <div class="hero-imgs">
    <img src="<photo 1>" alt="" />
    <img src="<photo 2>" alt="" />
    <img src="<photo 3>" alt="" />
    <img src="<photo 4>" alt="" />
    <img src="<photo 5>" alt="" />
    <img src="<photo 6>" alt="" />
    <img src="<photo 7>" alt="" />
  </div>
</section>

<div class="website-content">
  <nav>
    <div class="logo"><p>Logo</p></div>
    <div class="site-info"><p>(Photographer, creative director, filmmaker)</p></div>
    <div class="menu"><p>Menu</p></div>
  </nav>

  <div class="header"><h1>Marlow</h1></div>
</div>

The .digit-1 and .digit-2 reels are authored fully in HTML; the .digit-3 reel is authored with only its first 10 nums and then extended by JS (see GSAP setup). <h1> holds a single surname word (use a neutral placeholder like Marlow — an arbitrary 6-letter surname; the character count drives the reveal stagger). Do not use any real client brand.

Styling

Single flat palette:

  • #ebdc0b — chrome/acid yellow: the full-page background (html, body), the nav text color, and the headline color.
  • #000 — black: default text (the Loading label + counter digits sit black-on-yellow) and the progress-bar fill.

Global reset: * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100vw; height:100vh; overflow:hidden; background:#ebdc0b }.

Typography (two display faces; if the exact fonts are unavailable, substitute a bold condensed display serif for the loader/nav and a very thin, light, wide sans for the headline):

  • Loader label, counter and nav use family "Timmons NY" (a tall condensed display face).
  • Headline <h1> uses family "PP Neue World", weight 200 (ultra-thin).

Element specifics (initial states matter — the animation depends on them):

  • imgposition:absolute; width:100%; height:100%; object-fit:cover; and the critical initial clip-path clip-path: polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%) (all four points collapsed onto the right edge → the image is an invisible zero-width sliver pinned right).
  • .herowidth:100vw; height:100vh; padding:3em (the 3em padding leaves a yellow border framing the photos).
  • .hero-imgsposition:relative; width:100%; height:100%; overflow:hidden; z-index:0 (all 7 images stack absolutely inside it).
  • .pre-loaderposition:fixed; top:0; right:0; width:200%; height:100%; padding:2em; display:flex; justify-content:flex-end; align-items:flex-end; gap:0.5em; overflow:hidden; z-index:2. It is anchored to the bottom-right; the label + counter + progress bar sit in a row at the bottom-right corner (the 200% width overflows off-screen left).
  • .pre-loader pwidth:max-content; text-transform:uppercase; font-family:"Timmons NY"; font-size:60px; line-height:60px.
  • .counterheight:100px; display:flex; font-family:"Timmons NY"; font-size:100px; font-weight:400; line-height:150px; clip-path: polygon(0 0, 100% 0, 100% 100px, 0 100px). The clip-path is a 100px-tall mask window; each .num is 150px tall (line-height), so only one digit shows through the window at a time — this is what turns the reels into an odometer.
  • .digit-1, .digit-2, .digit-3, .digit-4position:relative; top:-15px (optical centering inside the 100px clip window).
  • .offsetposition:relative; right:-7.5px (kerning nudge for the narrow "1" glyph).
  • .progress-barposition:relative; top:-15px; width:0%; height:4px; background:#000 (a thin black bar that starts at zero width).
  • .website-contentposition:absolute; top:0; left:0; width:100%; height:100%; z-index:1 (sits above the images).
  • navposition:fixed; top:0; width:100%; display:flex; padding:2em. nav > div { flex:1; font-family:"Timmons NY"; font-size:36px; font-weight:lighter; color:#ebdc0b; text-transform:uppercase }. .site-info { text-align:center }, .menu { text-align:right } → logo left, credits centered, menu right.
  • .headerposition:absolute; top:50%; left:50%; transform:translate(-50%,-50%).
  • h1text-transform:uppercase; font-family:"PP Neue World"; font-size:20vw; font-weight:200; color:#ebdc0b; clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%). The clip-path is a rectangular mask that hides anything pushed below the headline box.
  • h1 span (produced by the JS split) — position:relative; top:400px (every character starts 400px below its resting line, hidden under the h1 clip mask).

GSAP effect (be exhaustive)

Everything runs inside one DOMContentLoaded handler. There is no timeline object — each behavior is an independent gsap.to scheduled by delay. ease strings assume core GSAP eases (power2/3/4.inOut/out).

Setup (before the tweens)
  1. gsap.set("nav", { y: -150 }) — park the whole nav 150px above the top edge.
  2. Grab const digit1 = ".digit-1", digit2 = ".digit-2", digit3 = ".digit-3".
  3. Headline split — hand-rolled: read .header h1 innerText, split into characters, wrap each char in <span>…</span>, join, and set as the h1's innerHTML. (Marlow → 6 spans. Each span inherits the CSS top:400px.)
  4. Extend the units reel — the .digit-3 reel authored with 10 nums (09) is grown by JS: append two more full 0–9 runs (nested loop i<2, j<10, each a <div class="num">j</div>), then append one final <div class="num">0</div>. Result: .digit-3 ends with 31 nums total, and its last num is 0.

Final resting readout of the four reels (hundreds/tens/units/%) is 100% (digit-1→1, digit-2→0, digit-3→0, digit-4 is the static %).

A — Odometer counter roll (three reels)

Helper:

function animate(digit, duration, delay = 1) {
  const numHeight = digit.querySelector(".num").clientHeight; // = 150px (line-height)
  const totalDistance = (digit.querySelectorAll(".num").length - 1) * numHeight;
  gsap.to(digit, { y: -totalDistance, duration, delay, ease: "power2.inOut" });
}

Each reel translates up by (numCount − 1) × 150px so its last num lands in the clip window. Calls, in order:

  • animate(digit3, 5) — units reel (31 nums): y: -4500, duration 5, delay 1 (default), power2.inOut.
  • animate(digit2, 6) — tens reel (11 nums): y: -1500, duration 6, delay 1, power2.inOut.
  • animate(digit1, 2, 5) — hundreds reel (2 nums): y: -150, duration 2, delay 5, power2.inOut.

So the units and tens reels start rolling at t=1 (units settles at t≈6, tens at t≈7); the hundreds reel flips 0→1 between t=5 and t=7. All three land at ≈100% by t≈7.

B — Progress bar (two overlapping tweens on the same element)
gsap.to(".progress-bar", { width: "30%",  duration: 2, ease: "power4.inOut", delay: 7 });
gsap.to(".progress-bar", {
  width: "100%", opacity: 0, duration: 2, ease: "power3.out", delay: 8.5,
  onComplete: () => gsap.set(".pre-loader", { display: "none" }),
});

The bar fills 0% → 30% starting at t=7 (power4.inOut, 2 s), then a second tween (starting at t=8.5, power3.out, 2 s) drives it on to 100% while fading its opacity to 0; on completion the entire .pre-loader is hidden (display:none). (Default GSAP overwrite is off, so the two width tweens briefly co-run around t=8.5–9 — that momentary overlap is intended, the net read is "bar fills and dissolves".)

C — Stacked photo reveal (the star effect)
gsap.to(".hero-imgs > img", {
  clipPath: "polygon(100% 0%, 0% 0%, 0% 100%, 100% 100%)",
  duration: 1, ease: "power4.inOut", stagger: 0.25, delay: 9,
});

Each image animates its clip-path from the collapsed right-edge sliver polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%) to the full rectangle polygon(100% 0%, 0% 0%, 0% 100%, 100% 100%) — i.e. the two left-hand vertices slide from x=100% to x=0%, so each photo wipes open from the right edge leftward. Duration 1 s each, power4.inOut, stagger 0.25 s across the 7 images (last image starts at t≈10.5), all beginning at delay 9. Because they're stacked absolutely, each new reveal paints over the previous one; the 7th photo is what's left on screen.

D — Hero zoom-in
gsap.to(".hero", { scale: 1.3, duration: 3, ease: "power3.inOut", delay: 9 });

The whole .hero (photo stack) scales 1 → 1.3 over 3 s starting at t=9, so the images push in cinematically as they reveal.

E — Nav drop-in
gsap.to("nav", { y: 0, duration: 1, ease: "power3.out", delay: 11 });

Nav slides down from y:-150 to y:0 over 1 s at t=11.

F — Headline rise (letter by letter)
gsap.to("h1 span", { top: "0px", stagger: 0.1, duration: 1, ease: "power3.out", delay: 11 });

Each character span animates its CSS top from 400px → 0px, power3.out, 1 s, stagger 0.1 s, delay 11. Since <h1> has a rectangular clip-path mask, the letters emerge from below and rise into the box one after another (Marlow → 6 chars, last starts at t≈11.5).

Approximate timeline (seconds from load)

| t | event | |---|-------| | 0 | nav parked at y:-150; reels read 000%; photos are right-edge slivers | | 1 | units reel (A, dur 5) + tens reel (A, dur 6) start rolling | | 5 | hundreds reel (A, dur 2) starts its 0→1 flip | | ~6 | units reel settles on 0 | | ~7 | tens + hundreds settle → 100%; progress bar starts 0→30% (B) | | 8.5 | progress bar 30→100% + fade-out begins (B) | | 9 | photos begin staggered clip-path reveal (C); hero starts scale→1.3 (D) | | ~10.5 | progress bar done → .pre-loader hidden; last photo reveal fires | | 11 | nav drops in (E); headline letters begin rising (F) | | ~12.5 | headline fully risen; hero settled |

Assets / images

7 full-bleed editorial portrait photographs, cover-cropped (object-fit: cover, so aspect ratio is flexible — shot roughly portrait ~2:3). They share a warm, cinematic rust/orange palette: a red-haired model against burnt-orange velvet furniture and sunlit concrete, styled with black heels, a vintage cream rotary phone, crystal-chandelier light refractions, and rust-toned gowns/blouses. Any cohesive set of 7 warm editorial fashion/portrait frames works — they are only ever seen as the sequential clip-path reveal, so ordering matters more than exact content (a strong final frame = the 7th, which stays on screen). No brand marks.

Behavior notes

  • Autoplay once on DOMContentLoaded; no scroll, hover, or click. Whole intro ≈ 12.5 s.
  • The counter relies on the rendered clientHeight of a .num (150px), so the .digit-3 reel must be extended before animate() runs (same tick is fine).
  • Responsive (@media (max-width:900px)): .pre-loader padding drops to 1em; .counter font-size → 70px; .pre-loader pfont-size:40px; line-height:64px; .offset nudge → right:-5px. The animation logic is unchanged; the headline stays fluid at 20vw.
  • Keep body { overflow:hidden } and .hero-imgs { overflow:hidden } so the scaling photos and off-screen loader never spill.
  • No reduced-motion handling exists in the reference; add one if desired but it is not part of the reference behavior.