All components

Outfit Landing Page Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

Preloader reveal for a fashion-archive landing page: six stacked centered images with random tilts scale up and un-clip with stagger while a SplitText title animates in and a 000-to-100 counter ticks; everything then exits and the preloader clips away upward, revealing the hero with masked char/word slide-ups on the header, nav and footer. Plays automatically once on page load.

How it's built

Categorypreloader
Techgsap
GSAP pluginsSplitText, CustomEase
Complexitypage
Performance costmedium
Mobile-safeyes

preloader reveal clip-path splittext counter stagger intro landing hero

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 — Stacked-Image Preloader to Editorial Hero

Goal

Build a full-screen fashion-archive landing hero with a cinematic preloader-to-hero reveal that plays automatically once on page load (~6 seconds total). On a near-black full-screen panel, six portrait images stacked dead-center — each pre-tilted at a different random angle — scale up from nothing and un-clip open one after another, while a giant ARCHIVE title reveals character by character in random order and a small 000 → 100 counter ticks beside it. Everything then reverses: the counter and title characters slide up out of view, the six images collapse back to nothing in reverse order, and the whole dark panel wipes upward via an animated clip-path to uncover the pale hero underneath — where the huge ARCHIVE headline, the nav links and the three footer labels all rise up into place from behind masks. One single GSAP timeline drives the entire sequence.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and CustomEase. 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 run the timeline immediately as the module executes (module import guarantees the DOM elements exist).

Layout / HTML

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

<div class="preloader">
  <div class="preloader-images">
    <div class="preloader-img"><img src="..." alt="" /></div>
    <div class="preloader-img"><img src="..." alt="" /></div>
    <div class="preloader-img"><img src="..." alt="" /></div>
    <div class="preloader-img"><img src="..." alt="" /></div>
    <div class="preloader-img"><img src="..." alt="" /></div>
    <div class="preloader-img"><img src="..." alt="" /></div>
  </div>

  <div class="preloader-header">
    <h1>Archive</h1>
    <div class="preloader-counter">
      <p>000</p>
    </div>
  </div>
</div>

<nav>
  <div class="nav-logo"><a href="#">Archive</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="header"><h1>Archive</h1></div>
  <div class="hero-footer">
    <p>Permanence</p>
    <p>Craftsmanship</p>
    <p>Expression</p>
  </div>
</section>

Notes:

  • .preloader-images holds exactly 6 .preloader-img wrappers, each with one <img>. All six are absolutely stacked on the exact same center point.
  • .preloader-counter is nested inside .preloader-header, positioned to sit just above the top-right corner of the big preloader title.
  • Use "Archive" as the neutral placeholder brand/name — it appears three times: the preloader title, the nav logo, and the hero headline (all rendered uppercase via CSS).

Styling

Font (single web font): PP Neue Montreal — import via @import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");. Everything (h1, a, p) is font-family: "PP Neue Montreal", sans-serif; font-weight: 500; line-height: 1; letter-spacing: -0.02em; text-decoration: none.

Palette (CSS custom properties):

  • --base-100: #fff — white (preloader text/title, counter, images layer text)
  • --base-200: #e0e2db — pale warm sage/off-white (the hero background)
  • --base-300: #141414 — near-black (the preloader background, nav-link + hero text color)

Global:

  • * { margin:0; padding:0; box-sizing:border-box }.
  • img { width:100%; height:100%; object-fit:cover }.

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

  • .preloader: position:fixed; inset:0; width:100%; height:100svh; background:var(--base-300); color:var(--base-100); overflow:hidden; z-index:2; will-change:clip-path. Initial clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%) (full rectangle — covers the whole viewport).
  • .preloader-images: position:absolute; top:0; left:0; width:100%; height:100%.
  • .preloader-img: position:absolute; top:50%; left:50%; transform: translate(-50%,-50%) scale(0); transform-origin:center center; width:250px; height:300px; will-change:transform, clip-path. Initial clip-path: polygon(20% 20%, 80% 20%, 80% 80%, 20% 80%) (a centered inset rectangle = the middle 60%×60% of the element is visible). All six share the identical center position — they are a perfect stack. (Per-image rotation is applied by JS gsap.set, see below.)
  • .preloader-header: position:absolute; top:50%; left:50%; transform: translate(-50%,-50%) (dead center). Its h1: text-transform:uppercase; font-size: clamp(2rem, 10vw, 15rem); line-height:0.85.
  • .preloader-counter: position:absolute; top:-1.5rem; left:calc(100% + 1.5rem); overflow:hidden (sits just above and to the right of the title's top-right corner; the overflow:hidden masks the counter text). Its p: color:var(--base-100); font-size: clamp(1rem, 1.5vw, 2rem); line-height:0.85.
  • 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. nav a: color:var(--base-300); line-height:1.
  • .hero: position:relative; width:100%; height:100svh; background:var(--base-200); color:var(--base-300); overflow:hidden.
  • .header: position:absolute; top:50%; left:50%; transform: translate(-50%,-50%); display:flex; justify-content:center; width:100%; height:max-content; overflow:hidden (the overflow:hidden on this container is what masks the headline characters). Its h1: text-transform:uppercase; font-size: clamp(2.5rem, 15vw, 25rem); line-height:0.85.
  • .hero-footer: position:absolute; width:100%; bottom:0; padding:2rem; display:flex; justify-content:space-between; align-items:flex-end (three labels spread across the bottom).

Split-piece init states (these classes are produced by SplitText below; the CSS pre-hides them by parking them one line below):

.char,
.word,
.preloader-counter p {
  transform: translateY(100%);
  will-change: transform;
}

So every split character/word, and the counter <p>, start pushed down by 100% (hidden below their mask / clipping container) before the timeline runs.

GSAP effect (be exact)

Setup
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { CustomEase } from "gsap/all";

gsap.registerPlugin(CustomEase, SplitText);

CustomEase.create("hop",  "0.8, 0, 0.2, 1");   // used for the image OPEN + all final hero reveals
CustomEase.create("hop2", "0.9, 0, 0.1, 1");   // slightly steeper — used for the preloader interior + exit

// Split helper: split into chars/words, tag each piece with a class,
// and (optionally) wrap each piece in an overflow-hidden mask.
const splitText = (selector, type, className, mask = true) =>
  SplitText.create(selector, {
    type: type,                       // "chars" | "words"
    [`${type}Class`]: className,      // charsClass / wordsClass = className
    ...(mask && { mask: type }),      // mask each char/word individually
  });

const preloaderHeaderSplit = splitText(".preloader-header h1", "chars", "char");        // masked chars
const navSplit             = splitText("nav a",               "words", "word");        // masked words
const headerSplit          = splitText(".header h1",          "chars", "char", false); // chars, NOT masked (the .header container clips instead)
const footerSplit          = splitText(".hero-footer p",      "words", "word");        // masked words

Note the fourth argument: the hero .header h1 is split into chars without per-char masks — its clipping comes from the overflow:hidden on the .header container itself. The other three splits use per-piece masks.

Pre-set the six image tilts

Before the timeline, stamp a fixed per-index rotation onto the stacked images (in degrees, in DOM order):

const preloaderImgInitRotations = [7.5, -2.5, -10, 12.5, -5, 5];
gsap.set(".preloader-img", { rotate: (i) => preloaderImgInitRotations[i] });

Each of the six images is tilted a different amount, so as they scale up they read as a lightly fanned, scattered stack rather than a clean pile.

Timeline

Everything runs on one timeline with a leading delay:

const tl = gsap.timeline({ delay: 0.5 });

Position params below are absolute times (bare numbers, or numeric strings like "0.35" which GSAP also treats as absolute seconds) or "<" (align to the start of the previously-added tween). All times are measured from the timeline start (i.e. after the 0.5s delay).

1 — Images scale up + un-clip open (position: default → t = 0)

tl.to(".preloader-img", {
  scale: 1,
  clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
  duration: 1,
  ease: "hop",
  stagger: 0.2,
});

Each stacked image grows scale 0 → 1 and its clip-path opens from the centered inset window (20% 20% … 80% 80%) out to the full element rectangle — so the images bloom open at their tilt, one every 0.2s, front to back.

2 — Preloader title characters slide in (position: "0.35")

tl.to(".preloader-header h1 .char", {
  y: "0%",
  duration: 1,
  ease: "hop2",
  stagger: { each: 0.125, from: "random" },
}, "0.35");

Each ARCHIVE character rises from translateY(100%) to 0% inside its mask, revealed in random order with 0.125s between pieces.

3 — Counter slides in AND counts up (position: "<" → same start as step 2, t = 0.35)

tl.to(".preloader-counter p", {
  y: "0%",
  duration: 1,
  ease: "hop2",
  onStart: () => {
    const counterEl = document.querySelector(".preloader-counter p");
    const counter = { value: 0 };
    gsap.to(counter, {
      value: 100,
      duration: 2,
      delay: 0.5,
      ease: "power2.inOut",
      onUpdate: () => {
        counterEl.textContent = String(Math.round(counter.value)).padStart(3, "0");
      },
    });
  },
}, "<");

The counter <p> rises into its overflow:hidden window (y 100% → 0%). Its onStart kicks off a separate tween on a plain { value: 0 } object → 100, duration: 2, delay: 0.5, ease: "power2.inOut"; each frame writes Math.round(value) zero-padded to 3 digits ("000" → "100") into the text. So the number ticks 000 → 100 over 2s, starting ~0.5s after the counter appears.

4 — Counter slides out up (position: t = 3.25)

tl.to(".preloader-counter p", { y: "-100%", duration: 0.75, ease: "hop2" }, 3.25);

Once counting is done, the counter <p> slides up and out of its window (y 0% → -100%).

5 — Preloader title characters slide out up (position: t = 3.25)

tl.to(".preloader-header h1 .char", {
  y: "-100%",
  duration: 0.75,
  ease: "hop2",
  stagger: { each: 0.125, from: "random" },
}, 3.25);

Simultaneously each title char slides up and out of its mask (y 0% → -100%), again in random order, 0.125s apart.

6 — Images collapse back down (reverse order) (position: t = 3.5)

tl.to(".preloader-images .preloader-img", {
  scale: 0,
  clipPath: "polygon(20% 20%, 80% 20%, 80% 80%, 20% 80%)",
  duration: 1,
  ease: "hop2",
  stagger: -0.075,
}, 3.5);

The six images reverse their entrance: scale 1 → 0 and clip-path re-closes to the inset window. The negative stagger (-0.075) means they animate in reverse DOM order (last image first).

7 — Preloader panel wipes upward (position: t = 4.35)

tl.to(".preloader", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
  duration: 1,
  ease: "hop2",
}, 4.35);

The whole dark panel's clip-path collapses its bottom edge up to the top edge (the two bottom coords go 100% → 0%), so the preloader wipes up and off, uncovering the pale hero underneath.

8 — Hero headline characters rise in (position: t = 4.65)

tl.to(".header h1 .char", {
  y: "0%",
  duration: 1,
  ease: "hop",
  stagger: { each: 0.075, from: "random" },
}, 4.65);

The huge hero ARCHIVE reveals character by character from translateY(100%) to 0% (clipped by the .header container's overflow:hidden), in random order, 0.075s apart.

9 — Nav words rise in (position: t = 4.75)

tl.to("nav a .word", { y: "0%", duration: 1, ease: "hop", stagger: 0.075 }, 4.75);

Each nav-link word slides up from behind its mask (y 100% → 0%), left-to-right, 0.075s apart.

10 — Footer words rise in (position: t = 4.75, simultaneous with step 9)

tl.to(".hero-footer p .word", { y: "0%", duration: 1, ease: "hop", stagger: 0.075 }, 4.75);

The three footer labels rise up the same way, starting together with the nav.

Timeline summary (absolute seconds, after the 0.5s lead delay)

| t (s) | what | |------|------| | 0–1+ | 6 images scale 0→1 + clip open, stagger 0.2 (hop) | | 0.35 | title chars rise in, random stagger 0.125 (hop2) | | 0.35 | counter <p> rises in; +0.5s later 000→100 over 2s (power2.inOut) | | 3.25 | counter <p> slides up out (0.75s, hop2) | | 3.25 | title chars slide up out, random stagger 0.125 (hop2) | | 3.5 | 6 images scale 1→0 + re-clip, negative stagger -0.075 (hop2) | | 4.35 | preloader panel clip-path wipes up off (1s, hop2) | | 4.65 | hero headline chars rise in, random stagger 0.075 (hop) | | 4.75 | nav words + footer words rise in, stagger 0.075 (hop) |

Total runtime ≈ 6.25s including the lead delay.

Ease reference
  • hop = CustomEase.create("hop", "0.8, 0, 0.2, 1") — steep symmetric in-out; used for the image OPEN (step 1) and all three final hero reveals (steps 8–10).
  • hop2 = CustomEase.create("hop2", "0.9, 0, 0.1, 1") — even steeper; used for the entire preloader interior + exit (steps 2, 3, 4, 5, 6, 7).
  • The counter number tween uses power2.inOut.

Assets / images

Six editorial "archive" images, all portrait / vertical aspect (each frame is displayed at 250×300px, i.e. ~5:6), object-fit: cover. They read as an eclectic, cross-genre mood-board set — no shared subject, unified only by a moody, elevated-fashion tone. In DOM order (the order also drives the per-image tilt):

  1. Soft over-exposed studio portrait — a young, fair-haired person shot head-on, heavily blown-out and hazy. Dominant colors: pale cream and washed pink, almost white.
  2. Cosmic nebula graphic — a deep-space scene of swirling clouds, scattered stars and a few small planets. Dominant colors: warm amber/orange against dark teal-blue and near-black.
  3. Abstract minimal sculpture — a smooth, soft-focus rounded form / bulge on a plain seamless surface. Dominant colors: near-monochrome light greys and off-white.
  4. Still-life with flowers — a bunch of purple irises in a clear glass vase on a dark green marble tabletop against a warm wall. Dominant colors: violet-purple blooms, green stems/marble, terracotta-red backdrop.
  5. Black-and-white beauty portrait — a woman in strict side profile with a sleek dark bob, pearl drop earrings and a black blazer, on a pale seamless backdrop. Dominant colors: grayscale — black hair/jacket, light grey ground.
  6. Macro botanical close-up — young sprouting seedlings / thin stems with small leaves, backlit. Dominant colors: pale golden-green shoots against a deep navy-purple background.

Any cohesive set of six moody editorial images works — only the count and order matter to the motion: each image gets a fixed tilt by index ([7.5, -2.5, -10, 12.5, -5, 5] degrees). If you have fewer than six, repeat to reach six.

Behavior notes

  • Autoplay once on load; 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 full-height preloader/hero.
  • Keep the will-change hints (clip-path on .preloader; transform, clip-path on .preloader-img; transform on the split pieces + counter) — they matter for smooth clip-path and transform animation.
  • Responsive (@media max-width: 1000px): the counter re-anchors slightly (left: calc(100% + 0.5rem)), and .nav-links becomes a right-aligned vertical column (flex-direction:column; gap:0; text-align:right). The animation itself is unchanged — only layout adapts. All big type uses clamp() so it scales down fluidly on its own.