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 & Brand Design <br /> Photography & 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-containerat 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 itsp { 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; }. The700×500box (7:5 landscape) is centered on the click point (JS setsleft/top, andtranslateY(-50%)re-centers vertically).pointer-events: noneso cards never block subsequent clicks. Keep thetranslateY(-50%)— GSAP preserves it asyPercent: -50and animatesy(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; thecursor.pngsparkle fills it (via the globalimg100% rule). It also sits atz-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:
- Play the click SFX:
new Audio("/c/savoirfaire-lp/click-sfx.mp3").play();(a freshAudioeach click, so overlapping clicks stack the sound). - Pick the type:
const itemType = Math.random() < 0.5 ? "video" : "image";(50/50). - Build the element into a throwaway wrapper div's
innerHTML, then grabcontainer.firstChild: - 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>. - Image:
imgNumber = Math.floor(Math.random() * 6) + 1(1–6) →<div class="img-container"><img src="/c/savoirfaire-lp/img-${n}.jpg" alt=""/></div>. - Append the element to
.items-container. - Position it at the pointer via inline style:
left = (event.clientX - 700/2) + "px"(elementWidth = 700),top = event.clientY + "px". - Random tilt:
randomRotation = Math.random() * 10 - 5→ a value in [-5°, +5°]. - Seed the transform instantly:
gsap.set(appendedElement, {
scale: 0,
rotation: randomRotation,
transformOrigin: "center",
});
- Run the lifetime timeline (
const tl = gsap.timeline();), withrandomScale = 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 → randomScaleover 0.5s with a 0.1s delay, default ease (power1.out). The card also carries its staticrotationandtransformOrigin: centerfrom thegsap.set. - Tween B (drift): inserted at position
"<"so it begins at the same time as tween A. It animatesyby-=500(relative, 500px upward) over 4s withease: "none"(constant-velocity float).opacity: 1is 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 fadesopacity 1 → 0over 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, playedautoplay 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 playsinlineto the spawned<video>(the source usesautoplay loop; the clips carry no important audio). - The custom cursor and the nav/title layer both live at
z-index: -1; the spawned.items-containercards render above them at the default stacking level.
</content> </invoke>