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.

Images

This component ships with 14 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/savoirfaire-lp/cursor.png
https://motionprompts.dev/c/savoirfaire-lp/img-1.jpg
https://motionprompts.dev/c/savoirfaire-lp/img-2.jpg
https://motionprompts.dev/c/savoirfaire-lp/img-3.jpg
https://motionprompts.dev/c/savoirfaire-lp/img-4.jpg
https://motionprompts.dev/c/savoirfaire-lp/img-5.jpg
… 8 more under https://motionprompts.dev/c/savoirfaire-lp/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --bone, --muted, --accent. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two triggers on the same element disagreeing about the same scrub, two smooth scrollers pulling on the same wheel event. The visible symptom is jitter or doubled speed, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script runs at the top level, the moment the module is evaluated: the itemsArray declaration, the cursor lookup, and the two document.addEventListener calls for mousemove and click all execute at import time, before your component has rendered the .cursor circle or the empty .items-container those clicks spawn cards into. Move all of it into a useEffect with an empty dependency array. Do not leave it in the component body: that re-runs on every render, and because both listeners bind to document rather than anything this component renders, a leftover copy from a missing cleanup does not just double a visual effect — it means every future click on the page, anywhere, spawns one media card per copy still attached (see Cleanup, below).

*(2) Element lookups* — cursor is captured once with document.querySelector(".cursor") and reused for the component's whole life; .items-container is looked up fresh with its own document.querySelector call inside the click handler, once per click. Put a ref directly on each instead: cursorRef on the .cursor div and itemsRef on .items-container. The click handler then appends into itemsRef.current instead of re-querying the document on every single click, and the mousemove handler reads cursorRef.current instead of a variable captured once at import time.

*(3) Cleanup* — Both listeners are deliberately global, not scoped to this component's markup: the cursor has to follow the pointer anywhere in the viewport, and a click anywhere on the page has to spawn a card. Leave both bound to document. That choice is exactly why the cleanup matters more than usual here — an orphaned document listener is invisible in the React tree, so removing this component's own JSX on unmount does nothing to it. Wrap the wiring in a gsap.context, and have the factory return its own teardown so ctx.revert() calls it alongside anything GSAP tracked:

useEffect(() => {
  const ctx = gsap.context((self) => {
    const onMouseMove = (e) => {
      const cursor = cursorRef.current;
      gsap.to(cursor, {
        x: e.clientX - cursor.offsetWidth / 2,
        y: e.clientY - cursor.offsetHeight / 2,
      });
    };

    const onClick = (event) => {
      self.add(() => {
        /* build the video/img element exactly as above, append it to itemsRef.current,
           gsap.set its initial scale + rotation, then run the pop-in / drift / fade timeline */
      });
    };

    document.addEventListener("mousemove", onMouseMove);
    document.addEventListener("click", onClick);
    return () => {
      document.removeEventListener("mousemove", onMouseMove);
      document.removeEventListener("click", onClick);
    };
  }, rootRef);

  return () => ctx.revert();
}, []);

The pop-in/drift/fade timeline is the one animation here that is never created while the factory runs — it is built later, once per click, from a listener the factory only attaches. onClick closes over self safely because it is defined inside the same factory call that received self as its argument; there is no re-entry into gsap.context and none of the temporal-dead-zone hazard that rules out naming ctx there. But a tween built inside that listener is not automatically part of the context just because the listener itself was registered from inside the factory — it has to be attributed at the moment it is actually created, with the immediate-invoke form of self.add, exactly as shown. Skip that, and ctx.revert() has nothing on record to kill: a card that was mid-drift when the route changed keeps drifting and fading for the rest of its cycle, writing styles onto a node the user can no longer see — .items-container is still its parentNode, just no longer attached to the visible document, so the removeChild call in its onComplete still succeeds without error. Nothing crashes, but it is exactly the kind of untracked, still-running resource gsap.context exists to prevent, and it keeps a full spawn cycle's worth of GSAP work alive for several seconds after nobody can see it.

Do not give onMouseMove the same wrapping. It fires on every pointer move, not once per user action, and self.add has no way to retire an entry once the tween it created finishes — each call is one more permanent record for ctx.revert() to walk later, for as long as the component stays mounted. Wrapping a handler that fires dozens of times a second turns a single page visit into an unbounded list, for a failure mode that, left unwrapped, is trivial by comparison: one stray tween writing to a cursor node that is a fraction of a second from being garbage-collected anyway. Reserve self.add for onClick, where calls are rate-limited by the user and the thing each one creates stays alive and visible for the rest of its multi-second lifecycle — long enough to be worth tracking.