All components

Savoir-Faire Click Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen dark editorial landing where every click spawns a random image or autoplaying video card at the pointer. Each card is set with gsap.set (scale 0, slight random rotation) then a gsap.timeline scales it up (random 0.5–1) and simultaneously drifts it 500px upward over 4s while fading in, then fades out and self-removes. A custom sparkle cursor trails the mouse via gsap.to with power2.out, and a click SFX plays on each spawn.

How it's built

Categoryinteractive
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

click-interaction custom-cursor spawn image-reveal video gsap timeline editorial luxury

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

Savoir-Faire Click Reveal — click-to-spawn floating media cards + sparkle cursor

Goal

Build a full-screen, black editorial landing page for a creative studio. A big uppercase serif wordmark sits at the bottom, a fixed nav sits at the top, and a custom white circular cursor with a black sparkle glyph trails the mouse. The star effect: every click anywhere on the page spawns a random media card (50/50 an image or an autoplaying video) at the pointer — it pops in from scale: 0 with a slight random tilt, then drifts 500px straight up over 4s while holding full opacity, and finally fades out and removes itself. A short click sound plays on each spawn. Cards pile up as the user keeps clicking.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no smooth-scroll library. Single import:

import gsap from "gsap";

Everything is mouse-driven (mousemove + click); there is no scroll behavior (the page is overflow: hidden, exactly one viewport tall).

Layout / HTML

<body>
  <div class="items-container"></div>          <!-- empty; spawned media cards are appended here -->

  <div class="cursor">                         <!-- custom cursor, follows the mouse -->
    <img src="/c/savoirfaire-lp/cursor.png" alt="" />
  </div>

  <div class="wrapper">
    <nav>
      <div class="nav-item">
        <p>Knowing by Building <br /> ATELIER OKO</p>
      </div>
      <div class="nav-item">
        <p>Digital &amp; Brand Design <br /> Photography &amp; Film Production</p>
        <p>Founded in 2020 <br /> Brooklyn, NY</p>
      </div>
    </nav>
    <div class="header">
      <h1>ATELIER OKO</h1>
    </div>
  </div>

  <script type="module" src="./script.js"></script>
</body>

Use a short invented studio name (e.g. ATELIER OKO) for the <h1> wordmark and the nav copy — no real brand names. The nav copy is neutral studio boilerplate (discipline lines + "Founded in … / City").

Styling

Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }.

  • body: width: 100vw; height: 100vh; overflow: hidden; background: #000; font-family a modern grotesque sans (e.g. "PP Neue Montreal", "Helvetica Neue", Arial, sans-serif).
  • .wrapper: position: relative; width: 100%; height: 100%; z-index: -1; — the nav + title layer sits behind the spawned cards (which live in .items-container at the default stacking level, so cards always render on top of the text).
  • nav: position: fixed; top: 0; width: 100vw; display: flex; padding: 2em;.
  • .nav-item:nth-child(1) { flex: 3; }
  • .nav-item:nth-child(2) { flex: 2; display: flex; } and its p { flex: 1; } (two columns of small copy).
  • p: font-weight: 500; color: #fff; opacity: 0.5; letter-spacing: 0; (dim white).
  • .header: position: absolute; bottom: 0; width: 100%;.
  • .header h1: text-transform: uppercase; font-family: "PP Editorial Old", "Times New Roman", serif; font-size: 14vw; font-weight: 300; color: #fff; text-align: center; line-height: 100%; — a giant, thin, light editorial serif spanning the bottom.
  • Global media rule: video, img { width: 100%; height: 100%; object-fit: cover; }.
  • Media card containers.video-container, .img-container { position: absolute; width: 700px; height: 500px; transform: translateY(-50%); pointer-events: none; }. The 700×500 box (7:5 landscape) is centered on the click point (JS sets left/top, and translateY(-50%) re-centers vertically). pointer-events: none so cards never block subsequent clicks. Keep the translateY(-50%) — GSAP preserves it as yPercent: -50 and animates y (px) on top of it.
  • .cursor: position: absolute; width: 150px; height: 150px; background: #fff; border-radius: 100%; display: flex; justify-content: center; align-items: center; font-size: 50px; z-index: -1; — a white circle; the cursor.png sparkle fills it (via the global img 100% rule). It also sits at z-index: -1, so a spawned card passing over it will cover it.

GSAP effect (exhaustive)

1. Cursor follow (document mousemove)

On every mousemove, tween the .cursor element toward the pointer, offset so the circle is centered on the cursor:

document.addEventListener("mousemove", (e) => {
  gsap.to(cursor, {
    x: e.clientX - cursor.offsetWidth / 2,   // -75px offset (half of 150)
    y: e.clientY - cursor.offsetHeight / 2,
    duration: 0.5,
    ease: "power2.out",
  });
});

This is a lagging follow — the 0.5s power2.out tween makes the circle glide/trail behind the real pointer. (Before the first mousemove the cursor rests at the top-left corner, x/y = 0.)

2. Click → spawn a media card (document click)

On every click anywhere:

  1. Play the click SFX: new Audio("/c/savoirfaire-lp/click-sfx.mp3").play(); (a fresh Audio each click, so overlapping clicks stack the sound).
  2. Pick the type: const itemType = Math.random() < 0.5 ? "video" : "image"; (50/50).
  3. Build the element into a throwaway wrapper div's innerHTML, then grab container.firstChild:
  4. Video: videoNumber = Math.floor(Math.random() * 7) + 1 (1–7) → <div class="video-container"><video autoplay loop><source src="/c/savoirfaire-lp/vid-${n}.mp4" type="video/mp4"/></video></div>.
  5. Image: imgNumber = Math.floor(Math.random() * 6) + 1 (1–6) → <div class="img-container"><img src="/c/savoirfaire-lp/img-${n}.jpg" alt=""/></div>.
  6. Append the element to .items-container.
  7. Position it at the pointer via inline style: left = (event.clientX - 700/2) + "px" (elementWidth = 700), top = event.clientY + "px".
  8. Random tilt: randomRotation = Math.random() * 10 - 5 → a value in [-5°, +5°].
  9. Seed the transform instantly:
gsap.set(appendedElement, {
  scale: 0,
  rotation: randomRotation,
  transformOrigin: "center",
});
  1. Run the lifetime timeline (const tl = gsap.timeline();), with randomScale = Math.random() * 0.5 + 0.5 → a final scale in [0.5, 1.0]:
// A — pop in
tl.to(appendedElement, {
  scale: randomScale,
  duration: 0.5,
  delay: 0.1,
});

// B — drift up + hold visible (starts together with A, position "<")
tl.to(appendedElement, {
  y: () => `-=500`,        // move 500px UP (function value)
  opacity: 1,             // element is already opaque -> effectively a 4s "hold"
  duration: 4,
  ease: "none",           // linear drift
}, "<")

// C — fade out, then self-destruct (starts 0.5s before the timeline's current end)
.to(appendedElement, {
  opacity: 0,
  duration: 1,
  onComplete: () => {
    appendedElement.parentNode.removeChild(appendedElement);
    // (also splice it out of an itemsArray bookkeeping list if you keep one)
  },
}, "-=0.5");

Timeline semantics — reproduce exactly:

  • Tween A (pop): scale 0 → randomScale over 0.5s with a 0.1s delay, default ease (power1.out). The card also carries its static rotation and transformOrigin: center from the gsap.set.
  • Tween B (drift): inserted at position "<" so it begins at the same time as tween A. It animates y by -=500 (relative, 500px upward) over 4s with ease: "none" (constant-velocity float). opacity: 1 is a no-op hold because the element starts fully opaque (opacity was never zeroed) — its role is just to keep the card visible for the full 4s.
  • Tween C (fade + remove): inserted at position "-=0.5", i.e. it starts 0.5s before the end of the timeline built so far (the end of the 4s drift). It fades opacity 1 → 0 over 1s and on complete removes the node from the DOM. So the fade begins while the card is still drifting and finishes ~0.5s after the drift stops. Net card lifetime ≈ 4.6s.

Every click spawns an independent element + timeline, so many cards float and fade concurrently. Nothing is pooled — each card is created on click and deleted on its own onComplete.

Assets / images

  • 6 editorial photographs img-1.jpg … img-6.jpg — moody, high-end fashion/beauty/still-life imagery (studio portraits, close-up beauty shots, luxury product shots). Displayed in a 700×500 (7:5 landscape) box, object-fit: cover, so any roughly landscape editorial photo works. Coherent dark/cinematic palette. No logos or brand marks.
  • 7 short video loops vid-1.mp4 … vid-7.mp4 — silent, muted-safe editorial/fashion film clips, same 7:5 landscape crop, played autoplay loop. Any short cinematic b-roll works.
  • 1 cursor glyph cursor.png — a small black four-pointed sparkle/star on a transparent background, centered so it reads well filling the 150px white circle.
  • 1 click SFX click-sfx.mp3 — a short, soft UI click/pop, played once per click.

Behavior notes

  • Desktop / mouse-driven only — no touch fallback, no scroll, no ScrollTrigger, no reduced-motion handling in the original. The page never scrolls (overflow: hidden, single viewport).
  • Cards are pointer-events: none, so they never intercept clicks — you can keep spawning through an existing pile.
  • The randomised type (image vs video), source number, tilt (±5°), and final scale (0.5–1.0) mean no two spawns look identical.
  • For reliable autoplay across browsers you may add muted playsinline to the spawned <video> (the source uses autoplay loop; the clips carry no important audio).
  • The custom cursor and the nav/title layer both live at z-index: -1; the spawned .items-container cards render above them at the default stacking level.

</content> </invoke>