모션·인터랙션 / Cadence
Cursor Trail
커서 · 호버 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Cursor Trail
Photos follow the cursor: each print wipes open in the direction you move, leans into the
motion, drifts on a little and is wiped away the same way. Slow movement drops large prints,
fast movement small ones. Finger drags draw the trail on touch screens. While nobody moves,
a virtual cursor traces a slow figure-eight so the page never sits empty.
Dependencies: GSAP 3.15 (jsDelivr).
Tweak (constants at the top of the script):
- WIDTH_SLOW / WIDTH_FAST: print width at low / high speed (260px / 150px),
SPEED_SLOW / SPEED_FAST: the speeds that map to them (0.25 / 2.2 px per ms)
- SPACING_RATIO: a new print every half print-width; DELAY_BETWEEN: at least 70ms apart
- ENTER: clip-path wipe open, 0.9s expo.out; INNER_SCALE: the photo settles from 1.25×
- LEAN_MAX / LEAN_PER_SPEED / LEAN_REST: lean into horizontal motion (max 8deg, relaxes to 40%)
- DRIFT_TIME / DRIFT_MAX / DRIFT: keeps travelling 120ms of velocity (max 70px), 1.2s power3.out
- HOLD / EXIT: wiped away after 0.55s, 0.7s power3.inOut, to scale 0.96
- MAX_VISIBLE: at most 8 prints on screen
- IDLE_AFTER / IDLE_PATH: idle figure-eight after 3.5s of stillness
Inspired by "GSAP Cursor Trailer Effect" by Nemanja Kočović (thingbynemanja)
https://codepen.io/thingbynemanja/pen/ogjaaNE
MIT License · Copyright (c) 2026 Nemanja Kočović
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cursor Trail</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #fff; }
/* The whole viewport is the stage. */
.stage {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none; /* finger drags draw the trail instead of scrolling */
user-select: none;
-webkit-user-select: none;
}
/* One print. Positioned and animated by the script (x/y are its centre). */
.print {
position: absolute;
top: 0;
left: 0;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 6px;
background: #efefef;
pointer-events: none;
will-change: transform, clip-path;
}
.print img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
-webkit-user-drag: none;
}
</style>
</head>
<body>
<div class="stage" data-stage aria-label="Cursor trail"></div>
<!-- Swap the picsum.photos placeholders for your own 3:4 photos (one shoot reads best).
They are cycled in order. -->
<div class="photo-set" hidden>
<img src="https://picsum.photos/seed/cadence-1/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-2/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-3/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-4/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-5/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-6/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-7/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-8/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-9/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-10/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-11/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-12/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-13/600/800" alt="">
<img src="https://picsum.photos/seed/cadence-14/600/800" alt="">
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
const DELAY_BETWEEN = 70; // ms, minimum time between spawns
const SPACING_RATIO = 0.5; // a new print every half print-width
const WIDTH_SLOW = 260; // px, print width when moving slowly
const WIDTH_FAST = 150; // px, print width when moving fast
const SPEED_SLOW = 0.25; // px/ms, at or below → WIDTH_SLOW
const SPEED_FAST = 2.2; // px/ms, at or above → WIDTH_FAST
const ENTER = { duration: 0.9, ease: "expo.out" };
const INNER_SCALE = 1.25; // photo inside the print settles from this scale
const LEAN_MAX = 8; // deg
const LEAN_PER_SPEED = 6; // deg per px/ms of horizontal speed
const LEAN_REST = 0.4; // the lean relaxes to this fraction
const DRIFT_TIME = 120; // ms of velocity the print keeps travelling
const DRIFT_MAX = 70; // px
const DRIFT = { duration: 1.2, ease: "power3.out" };
const HOLD = 0.55; // s on screen after the entrance starts
const EXIT = { duration: 0.7, ease: "power3.inOut", scale: 0.96 };
const MAX_VISIBLE = 8;
const IDLE_AFTER = 3500; // ms of stillness before the idle path resumes
const IDLE_PATH = { speed: 0.95, rx: 0.3, ry: 0.22 }; // rad/s; radii as a fraction of the stage
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stage = document.querySelector("[data-stage]");
const sources = [...document.querySelectorAll(".photo-set img")].map((img) => img.currentSrc || img.src);
let index = 0;
let z = 1;
let last = null; // position of the last spawn
let lastTime = 0;
let prev = null; // previous pointer sample, for velocity
let velocity = { x: 0, y: 0 }; // px/ms, smoothed
let lastInput = -Infinity;
let restingPrint = null;
const live = [];
const clamp = gsap.utils.clamp;
// Smaller prints on small screens.
const sizeFactor = () => clamp(0.62, 1, Math.min(window.innerWidth, window.innerHeight * 1.4) / 1100);
// The print opens from the side you come from…
function closedFrom(dir) {
switch (dir) {
case "right": return "inset(0% 100% 0% 0% round 6px)";
case "left": return "inset(0% 0% 0% 100% round 6px)";
case "down": return "inset(0% 0% 100% 0% round 6px)";
default: return "inset(100% 0% 0% 0% round 6px)"; // up
}
}
// …and closes towards the far side, like a shutter passing.
function closedTo(dir) {
switch (dir) {
case "right": return "inset(0% 0% 0% 100% round 6px)";
case "left": return "inset(0% 100% 0% 0% round 6px)";
case "down": return "inset(100% 0% 0% 0% round 6px)";
default: return "inset(0% 0% 100% 0% round 6px)";
}
}
const OPEN = "inset(0% 0% 0% 0% round 6px)";
function makePrint(width) {
const item = document.createElement("div");
item.className = "print";
item.style.width = `${width}px`;
item.style.zIndex = z++; // every new print lands on top
const img = document.createElement("img");
img.src = sources[index];
img.alt = "";
img.draggable = false;
index = (index + 1) % sources.length;
item.appendChild(img);
stage.appendChild(item);
return { item, img };
}
function removePrint(item) {
const i = live.indexOf(item);
if (i > -1) live.splice(i, 1);
gsap.killTweensOf([item, item.firstChild]);
item.remove();
}
function exitPrint(item, dir, delay) {
if (reduceMotion) {
gsap.to(item, { opacity: 0, duration: 0.3, delay, onComplete: () => removePrint(item) });
return;
}
gsap.to(item, {
clipPath: closedTo(dir),
scale: EXIT.scale,
duration: EXIT.duration,
ease: EXIT.ease,
delay,
overwrite: "auto",
onComplete: () => removePrint(item),
});
}
function spawn(x, y, now) {
const speed = Math.hypot(velocity.x, velocity.y);
const t = clamp(0, 1, (speed - SPEED_SLOW) / (SPEED_FAST - SPEED_SLOW));
const width = gsap.utils.interpolate(WIDTH_SLOW, WIDTH_FAST, t) * sizeFactor();
// Distance and time gates.
if (last) {
const distance = Math.hypot(x - last.x, y - last.y);
if (distance < width * SPACING_RATIO) return;
if (now - lastTime < DELAY_BETWEEN) return;
}
const dx = last ? x - last.x : velocity.x;
const dy = last ? y - last.y : velocity.y;
const dir = Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? "right" : "left") : dy >= 0 ? "down" : "up";
last = { x, y };
lastTime = now;
if (restingPrint) {
exitPrint(restingPrint.item, restingPrint.dir, 0);
restingPrint = null;
}
const { item, img } = makePrint(width);
item.dataset.dir = dir;
live.push(item);
// Too many on screen: hurry the oldest out.
if (live.length > MAX_VISIBLE) {
const oldest = live[0];
gsap.killTweensOf(oldest);
exitPrint(oldest, oldest.dataset.dir, 0);
}
if (reduceMotion) {
gsap.set(item, { x, y, xPercent: -50, yPercent: -50, opacity: 0 });
gsap.to(item, { opacity: 1, duration: 0.3 });
exitPrint(item, dir, HOLD + 0.3);
return item;
}
const lean = clamp(-LEAN_MAX, LEAN_MAX, velocity.x * LEAN_PER_SPEED);
const driftX = clamp(-DRIFT_MAX, DRIFT_MAX, velocity.x * DRIFT_TIME);
const driftY = clamp(-DRIFT_MAX, DRIFT_MAX, velocity.y * DRIFT_TIME);
gsap.set(item, { x, y, xPercent: -50, yPercent: -50, rotation: lean, clipPath: closedFrom(dir) });
gsap.to(item, { clipPath: OPEN, duration: ENTER.duration, ease: ENTER.ease });
gsap.fromTo(img, { scale: INNER_SCALE }, { scale: 1, duration: ENTER.duration + 0.3, ease: ENTER.ease });
gsap.to(item, { x: x + driftX, y: y + driftY, rotation: lean * LEAN_REST, duration: DRIFT.duration, ease: DRIFT.ease });
exitPrint(item, dir, HOLD);
return item;
}
// Feed a pointer position (real or virtual) into the trail.
function track(x, y, now) {
if (prev) {
const dt = Math.max(1, now - prev.t);
const k = 0.35; // velocity smoothing
velocity.x += ((x - prev.x) / dt - velocity.x) * k;
velocity.y += ((y - prev.y) / dt - velocity.y) * k;
}
prev = { x, y, t: now };
spawn(x, y, now);
}
// Real input: mouse moves, or finger drags on touch screens.
function onPointer(e) {
const now = performance.now();
if (e.type === "pointerdown" && e.pointerType !== "mouse") {
// New touch: start fresh here, and drop a print right away.
prev = null;
last = null;
velocity = { x: 0, y: 0 };
}
lastInput = now;
track(e.clientX, e.clientY, now);
}
window.addEventListener("pointermove", onPointer, { passive: true });
stage.addEventListener("pointerdown", onPointer, { passive: true });
// Idle: a virtual cursor traces a slow figure-eight through the middle of the stage.
let idleT = 0;
let wasIdle = false;
gsap.ticker.add((time, deltaTime) => {
if (reduceMotion) return;
const now = performance.now();
const idle = now - lastInput > IDLE_AFTER;
if (!idle) {
wasIdle = false;
return;
}
if (!wasIdle) {
// Starting (again): don't connect the virtual cursor to the last real position.
prev = null;
last = null;
wasIdle = true;
}
idleT += (deltaTime / 1000) * IDLE_PATH.speed;
const w = stage.clientWidth;
const h = stage.clientHeight;
const x = w / 2 + Math.sin(idleT) * w * IDLE_PATH.rx;
const y = h / 2 + Math.sin(idleT * 2) * h * IDLE_PATH.ry;
track(x, y, now);
});
// Reduced motion: no wipe, drift, lean or idle path. Prints fade in and out,
// and one print rests in the centre until the visitor moves.
if (reduceMotion) {
const { item } = makePrint(WIDTH_SLOW * sizeFactor());
gsap.set(item, { x: stage.clientWidth / 2, y: stage.clientHeight / 2, xPercent: -50, yPercent: -50 });
restingPrint = { item, dir: "right" };
}
// Warm the cache so prints never appear empty.
sources.forEach((src) => {
const img = new Image();
img.src = src;
});
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Cursor Trail: photos that wipe open along the cursor's path **Goal:** build a full-screen cursor trail where photos are dropped along the pointer's path. Each print wipes open in the direction of travel, leans into the motion, drifts on a little and is wiped away again, so the trail reads like a contact sheet running past. ## Visual behaviour 1. **Stage.** A full-viewport white stage with `overflow: hidden`. Nothing is visible until the pointer moves (or the idle path starts, see step 8). 2. **Spawning.** On every pointer move, measure the pointer's velocity (px per ms), smoothed: `velocity += (raw - velocity) * 0.35` per sample. Then decide whether to drop a print: - Print width depends on speed: **260px** at or below **0.25 px/ms**, **150px** at or above **2.2 px/ms**, linear in between. On small screens multiply by `clamp(0.62, 1, min(viewportWidth, viewportHeight × 1.4) / 1100)`. - Spacing gate: only drop a print once the pointer is at least **half a print-width** from the previous print. - Time gate: at least **70ms** since the previous print. - The very first move always drops a print. 3. **Photos.** A set of portrait photos (3:4), cycled in order. One photo shoot works best, so the trail feels like a sequence. 4. **Direction.** From the vector between the previous print and this one, take the dominant axis: right, left, down or up. 5. **Entrance** (all at once, from the moment of spawning): - The print is centred on the pointer (`translate(-50%, -50%)`), every new print on top (increasing z-index). - **Wipe open:** `clip-path: inset()` animates from closed on the side you came from to fully open, **0.9s, `expo.out`** (≈ `cubic-bezier(0.16, 1, 0.3, 1)`). Moving right: from `inset(0 100% 0 0)` to `inset(0)`, so it opens left to right. Left, down and up are the mirror images. Keep the 6px rounded corners in the inset (`round 6px`). - **Photo settle:** the image inside scales from **1.25 to 1** over **1.2s**, `expo.out`. - **Lean:** the print starts rotated by `clamp(-8°, 8°, velocityX × 6)` and relaxes to **40%** of that lean. - **Drift:** the print travels on by the velocity × **120ms** on each axis, capped at **±70px**, over **1.2s**, `power3.out` (≈ `cubic-bezier(0.165, 0.84, 0.44, 1)`). The lean relaxes in the same tween. Fast strokes feel thrown, slow ones placed. 6. **Exit.** **0.55s** after spawning, the print is wiped away, continuing the same direction it opened (moving right: closes to `inset(0 0 0 100%)`, so the left edge sweeps across to the right), while scaling to **0.96**, **0.7s, `power3.inOut`** (≈ `cubic-bezier(0.77, 0, 0.175, 1)`). Remove it from the DOM when done. 7. **Cap.** At most **8** prints on screen. When a 9th spawns, cancel the oldest print's pending tweens and start its exit immediately. 8. **Idle path.** Until the visitor first moves, and again after **3.5s** without input, a virtual cursor traces a figure-eight through the centre of the stage and feeds the same spawning logic: `x = w/2 + sin(t) × w × 0.3`, `y = h/2 + sin(2t) × h × 0.22`, with `t` advancing at **0.95 rad/s**. When the idle path (re)starts, forget the previous print position so it doesn't connect to the last real one. 9. **Touch.** Finger drags draw the trail (pointer events, `touch-action: none` on the stage). A new touch resets the velocity and drops a print right away. ## Exact values | What | Value | |---|---| | Print size | 260px (slow) → 150px (fast), 3:4, 6px radius, `#efefef` placeholder | | Speed range | 0.25 → 2.2 px/ms | | Spacing | 0.5 × print width, min 70ms apart | | Wipe open | clip-path inset, 0.9s `expo.out` | | Photo settle | scale 1.25 → 1, 1.2s `expo.out` | | Lean | 6° per px/ms of horizontal speed, max ±8°, relaxes to 40% | | Drift | velocity × 120ms, max ±70px, 1.2s `power3.out` | | Hold | 0.55s from spawn | | Exit | clip-path to the far side + scale 0.96, 0.7s `power3.inOut` | | Max on screen | 8 | | Idle | after 3.5s still; figure-eight 0.95 rad/s, radii 30% × 22% of the stage | ## Markup ```html <div class="stage"></div> <!-- position: fixed; inset: 0; overflow: hidden; touch-action: none --> <!-- each print, created by the script: --> <div class="print"><img src="…" alt=""></div> ``` - `.print`: `position: absolute; top: 0; left: 0; aspect-ratio: 3/4; overflow: hidden; border-radius: 6px; pointer-events: none; will-change: transform, clip-path`. - `img`: `width: 100%; height: 100%; object-fit: cover`. - Preload all photos at start so prints never appear empty. ## Libraries - **GSAP 3** for the tweens (`gsap.set` for the start state, `gsap.to` for clip-path, drift and exit, `gsap.fromTo` for the inner scale) and `gsap.ticker` for the idle path. Use `overwrite: "auto"` on the exit tween. - **Plain alternative:** the Web Animations API (`element.animate`) can drive `clip-path`, `transform` and the inner scale with the cubic-bezier equivalents above, and a `requestAnimationFrame` loop can run the idle path. ## Responsive and reduced motion - Prints scale down on small screens with the size factor above (never below 62%). - `prefers-reduced-motion: reduce`: no wipe, drift, lean or idle path. Prints simply fade in (0.3s) and fade out (0.3s, after 0.85s). One print rests in the centre of the screen until the visitor first moves. ## Acceptance criteria - Moving the cursor slowly drops large, closely spaced prints. A fast flick drops smaller ones, thrown further along the stroke. - Every print opens from the side the cursor came from and closes towards the side it was heading, like a shutter passing. - Prints lean into horizontal motion and settle to a smaller lean while drifting. - Never more than 8 prints on screen; the trail clears about 1.25s after the cursor stops. - With no input, a figure-eight trail runs through the middle of the screen; it stops as soon as the visitor moves and resumes after 3.5s of stillness. - Finger drags draw the trail on touch screens without scrolling the page. - With reduced motion, prints only fade, and one print is visible at rest.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 39e004c9738aae395b12b0a6e2a1d129e6469e89c5f3fa16288639b21886155c