All components

PermianWorld Landing Page Reveal Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

Preloader reveal on page load: SplitText masked lines slide the preloader copy and counter up while a randomized counter climbs from 00 to 100 and a centered square revealer scales in stepped increments, then the black preloader collapses via clip-path as nav, hero image and hero content slide into place. Runs automatically on DOMContentLoaded.

How it's built

Categorypreloader
Techgsap
GSAP pluginsSplitText
Complexitypage
Performance costlight
Mobile-safeyes

preloader reveal splittext counter clip-path landing intro stagger

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 — Stepped-Square Preloader + Clip-Path Unmask

Goal

Build a full-screen editorial landing hero fronted by a cinematic preloader-to-hero reveal that plays automatically once on page load (~5.5 seconds). A black full-screen preloader panel holds two columns of small mono copy and a two-digit counter; the masked copy lines and the counter slide up into view, the counter ticks randomly from 00 → 100, and a centered olive-khaki square scales up from nothing to full-viewport in five discrete stepped increments (each with its own duration/ease so it grows in visible pulses rather than one smooth zoom). Then the whole black preloader wipes upward via an animated clip-path polygon while — in perfect sync — the nav bar, the hero background image, and the hero content caption all slide up from 35svh below into their final positions. One single GSAP timeline drives the whole thing; the counter is a separate randomized JS ticker.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText (the only plugin). No smooth-scroll library — the page does not scroll during the intro; it is a pure load-triggered timeline. Register with gsap.registerPlugin(SplitText) and fire everything on DOMContentLoaded.

Layout / HTML

Semantic structure (class names are load-bearing — the JS/CSS query them):

<div class="preloader">
  <div class="preloader-revealer"></div>

  <div class="preloader-copy">
    <div class="preloader-copy-col">
      <p>Handpicked collections shaped by artistry, balancing rare elements with a focus on purity.</p>
    </div>
    <div class="preloader-copy-col">
      <p>Explore timeless essentials built with care, thoughtfully designed to guide you.</p>
    </div>
  </div>

  <div class="preloader-counter">
    <p>00</p>
  </div>
</div>

<nav>
  <div class="nav-logo"><a href="#">Atelier Vale</a></div>
  <div class="nav-links">
    <a href="#">Collections</a>
    <a href="#">New Arrivals</a>
    <a href="#">The Atelier</a>
    <a href="#">Support</a>
  </div>
  <div class="nav-cta"><a href="#">Create Account</a></div>
</nav>

<section class="hero">
  <div class="hero-img"><img src="..." alt="" /></div>
  <div class="hero-content">
    <div class="product-name"><p>[ Ember No. 04 ]</p></div>
    <div class="product-link"><a href="#">View the Collection</a></div>
  </div>
</section>

Notes:

  • .preloader is a fixed full-screen panel that sits on top of everything (z-index: 2). .preloader-revealer is the centered square that scales up; the two .preloader-copy-col paragraphs and the .preloader-counter p are the mono copy/number inside it.
  • Use "Atelier Vale" as the neutral placeholder brand. Keep the copy text as-is (two short editorial paragraphs) and the product caption [ Ember No. 04 ] / View the Collection.
  • The nav has three flex zones: logo (left), links (center, 4 links), CTA (right).
  • .hero is a single section with a full-bleed background image and a small centered two-row caption near the bottom.

Styling

Font (Google Fonts): Geist Mono (variable, wght 100..900) is the only web font — import it. Everything is small mono type; there is no giant display headline.

Palette (CSS custom properties):

  • --base-100: #fff (white — inverted text, hero-name panel bg)
  • --base-200: #eff1eb (pale warm off-white — nav-link chip background)
  • --base-300: #5b553b (dark olive/khaki — default text color, the revealer square, the product-link bar)
  • --base-400: #000 (black — preloader background)

Global:

  • * { margin:0; padding:0; box-sizing:border-box }.
  • img { width:100%; height:100%; object-fit:cover }.
  • a, p { color:var(--base-300); text-decoration:none; text-transform:uppercase; font-family:"Geist Mono"; font-size:0.8rem; font-weight:500; letter-spacing:-0.0125rem; line-height:1; display:inline-block }.

Key elements and their initial states (the animation depends on these):

  • nav: position:fixed; top:0; left:0; width:100%; display:flex; gap:2rem; padding:2rem; z-index:1; will-change:transform. .nav-logo, .nav-cta { flex:1; display:flex }; .nav-cta { justify-content:flex-end }; .nav-links { flex:2; display:flex; justify-content:center; gap:0.5rem }.
  • nav a: height:max-content; color:var(--base-300); background-color:var(--base-200); padding:0.25rem 0.5rem (small pale chips). .nav-logo a is inverted: color:var(--base-100); background-color:var(--base-300) (white text on olive).
  • section.hero: position:relative; width:100%; height:100svh; overflow:hidden; will-change:transform.
  • .hero-img: position:absolute; top:0; left:0; width:100%; height:100%; will-change:transform (full-bleed image layer).
  • .hero-content: position:absolute; bottom:5rem; left:50%; transform:translateX(-50%); display:flex; flex-direction:column; will-change:transform (small centered two-row caption).
  • .product-name, .product-link: flex:1; width:100%; display:flex; justify-content:center; align-items:center; padding:0.75rem 2.5rem. .product-name { background-color:var(--base-100) } (white bar, olive text). .product-link { background-color:var(--base-300) } with .product-link a { color:var(--base-100) } (olive bar, white text).
  • .preloader: position:fixed; top:0; left:0; width:100%; height:100svh; display:flex; align-items:center; padding:2rem; background-color:var(--base-400); overflow:hidden; z-index:2; will-change:clip-path. Initial clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%) (full rectangle covering the screen). .preloader p { color:var(--base-100) } (white text on black).
  • .preloader-revealer: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(0); width:100%; aspect-ratio:1; background-color:var(--base-300); z-index:2; will-change:transform (a viewport-wide square, centered, starting at scale(0) → invisible).
  • .preloader-copy, .preloader-copy-col, .preloader-counter { flex:1; display:flex }. .preloader-counter { justify-content:flex-end } (counter pinned to the right). .preloader-copy p { width:75% } (the two copy paragraphs are constrained so each wraps to ~2 lines).
  • .line { will-change:transform; transform:translateY(100%) } — this class is produced by SplitText below; the CSS pre-hides every split line one line-height below its mask.

Stacking note: the copy columns and the counter are static flex children (auto z-index), while .preloader-revealer is z-index:2, so the growing olive square paints over the copy — as the square swells it engulfs the text.

GSAP effect (be exact)

Setup
gsap.registerPlugin(SplitText);

// Helper: split into LINES, masked, each line wrapped in overflow-hidden and given class "line"
const splitTextIntoLines = (selector, options = {}) =>
  SplitText.create(selector, {
    type: "lines",
    mask: "lines",
    linesClass: "line",
    ...options,
  });

splitTextIntoLines(".preloader-copy p");     // both copy paragraphs → masked .line spans
splitTextIntoLines(".preloader-counter p");  // the "00" → a single masked .line span

// Push the whole page content down, hidden below the preloader, ready to slide up:
gsap.set(["nav", ".hero-img", ".hero-content"], { y: "35svh" });
Counter — randomized JS ticker (NOT GSAP)

A standalone function animateCounter(selector, duration = 4.5, delay = 2) drives the two-digit number with setTimeout, called as animateCounter(".preloader-counter p", 4.5, 2):

  • Capture startTime = Date.now() immediately (at DOMContentLoaded), maxDuration = duration * 1000 = 4500ms, currentValue = 0.
  • After a delay * 1000 = 2000ms timeout, run a self-scheduling updateCounter():
  • elapsed = Date.now() - startTime; progress = elapsed / maxDuration.
  • While currentValue < 100 && elapsed < maxDuration: compute target = Math.floor(progress * 100) and a random jump = Math.floor(Math.random() * 25) + 5 (i.e. 5–29); set currentValue = Math.min(currentValue + jump, target, 100); write it as String(currentValue).padStart(2, "0") into the element's textContent; re-schedule via setTimeout(updateCounter, 200 + Math.random() * 100) (200–300ms between ticks).
  • Otherwise (time's up) set textContent = "100".
  • Net behavior: the counter is invisible/00 until ~t=2s, then jumps to the teens/20s and climbs with a stuttering, target-clamped, randomized cadence, reaching 100 at ~t=4.5s. Note textContent writes replace the SplitText .line wrapper with plain text — that's expected (the line-reveal below only animates the initial 00, then the ticker takes over as plain visible text).
The master timeline

One timeline: const tl = gsap.timeline();. Position params: "<" = align to the start of the previous tween; "-=1" = start 1s before the current timeline end. All scale tweens act on the same .preloader-revealer, chained sequentially (except the first, which is parallel to the line reveal).

  1. Copy + counter lines slide up (delay: 1 → starts at t=1):
tl.to([".preloader-copy p .line", ".preloader-counter p .line"], {
  y: "0%", duration: 1, stagger: 0.075, ease: "power3.out", delay: 1,
});

Every masked line rises from translateY(100%) (hidden below) to 0%, staggered 0.075s, decelerating (power3.out).

  1. Revealer square, step 1 (position "<" → parallel, starts t=1):
tl.to(".preloader-revealer", { scale: 0.1, duration: 0.75, ease: "power2.out" }, "<");

Square scale 0 → 0.1.

  1. Revealer step 2 (sequential, t=2): { scale: 0.25, duration: 1, ease: "power3.out" }0.1 → 0.25.
  2. Revealer step 3 (sequential, t=3): { scale: 0.5, duration: 0.75, ease: "power3.out" }0.25 → 0.5.
  3. Revealer step 4 (sequential, t=3.75): { scale: 0.75, duration: 0.5, ease: "power2.out" }0.5 → 0.75.
  4. Revealer step 5 (sequential, t=4.25): { scale: 1, duration: 1, ease: "power3.out" }0.75 → 1 (fills the viewport width; at aspect-ratio:1 it covers the screen). The five different durations/eases make the square appear to grow in discrete pulses.
  1. Preloader wipes upward (position "-=1" → starts t=4.25, 1s before step 6 ends):
tl.to(".preloader", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
  duration: 1.25, ease: "power3.out",
}, "-=1");

The clip-path's two bottom corners rise to meet the top (100% → 0% on the Y of the lower points), collapsing the black panel's height to zero at the top — the whole preloader (black bg + olive square + text) wipes up and off the top of the screen.

  1. Nav + hero slide up into place (position "<" → parallel with step 7, t=4.25):
tl.to(["nav", ".hero-img", ".hero-content"], {
  y: "0%", duration: 1.25, ease: "power3.out",
}, "<");

The nav bar, the hero background image, and the hero caption all slide from y: 35svh (below) up to 0, revealed exactly as the black preloader recedes upward.

Timeline summary (absolute seconds)

| t (s) | what | |------|------| | 1.0–2.0 | copy + counter masked lines slide up (y 100%→0%, stagger 0.075, power3.out) | | 1.0–1.75 | revealer square scale 0→0.1 (power2.out) | | 2.0–3.0 | revealer 0.1→0.25 (power3.out) | | 2.0–4.5 | counter ticks 00→100 (randomized JS, independent of timeline) | | 3.0–3.75 | revealer 0.25→0.5 (power3.out) | | 3.75–4.25 | revealer 0.5→0.75 (power2.out) | | 4.25–5.25 | revealer 0.75→1 (power3.out) | | 4.25–5.5 | preloader clip-path wipes up (dur 1.25, power3.out) + simultaneously nav/hero-img/hero-content slide y 35svh→0 (dur 1.25, power3.out) |

Total runtime ≈ 5.5s (counter reaches 100 at ~4.5s, ~1s before the panel clears).

Ease reference
  • power3.out — the line reveal, revealer steps 2/3/5, and both the clip-path wipe and the nav/hero slide.
  • power2.out — revealer steps 1 and 4 only.
  • No CustomEase, no ScrollTrigger, no lerp/rAF loop (the counter uses setTimeout, not requestAnimationFrame).

Assets / images

One full-screen hero background image (landscape, ~3:2), displayed full-bleed (object-fit: cover, fills .hero-img). Aspect ratio is flexible since it is cover-cropped. The real asset is a moody editorial product still-life on a near-black background: an open polished-chrome/silver compact case (round mirror lid raised, pressed powder pan below in a soft nude/peach beige tone) sitting on a dark satin surface, lit by a single soft highlight streak across the black backdrop. Dominant colors are deep black and dark charcoal with bright metallic silver reflections and a warm nude/peach accent from the powder. The dark, high-contrast frame lets the light nav chips read clearly, though it skews cooler/darker than the olive #5b553b type palette. Any dark, softly-lit macro product photograph works if unavailable.

Behavior notes

  • Autoplay once on load (DOMContentLoaded); no scroll, hover, or click triggers. The page does not scroll during the intro.
  • Uses 100svh (small viewport height) so mobile browser chrome doesn't clip the preloader or hero.
  • Keep the will-change hints (clip-path on .preloader, transform on the revealer, nav, hero layers, and split lines) — they matter for smooth clip-path and transform animation.
  • Responsive (@media max-width: 1000px): nav .nav-links is hidden (display:none); .preloader and .preloader-copy switch to flex-direction:column; .preloader-revealer widens to width:200% (so the square still covers the taller portrait viewport); .preloader-copy-col and .preloader-counter become align-items:center; .preloader-copy p goes full width:100%. The animation logic itself is unchanged — only layout adapts.