모션·인터랙션 / Cadence
3D Spiral
3D · 갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
3D Spiral
Twenty square photo cards climb a coiled helix in perspective: each rises from the bottom
to the top over one loop while circling the axis twice, with a gentle wobble in the coil
radius and depth dimming. Cards fade fully in at the bottom and out at the top. One
seamless loop every twelve seconds, drawn on a single Canvas 2D. Drag sideways or up /
down to scrub the loop (right or up = forward), with a little release inertia.
Dependencies: none (plain JavaScript, Canvas 2D).
Tweak (constants at the top of the script):
- DURATION 12 s per loop, COUNT 20 cards
- PARAMS.turns 2 (coil turns), PARAMS.height 100 (climb height), PARAMS.wobble 20 (coil wobble)
- EDGE_FADE 0.18: share of the climb spent fading in / out (smootherstep)
- Perspective: CAMERA_DISTANCE 2.6; depth dimming strength 0.7
- Card size: BASE_SIZE 0.26 × min(frame width, height) × perspective scale × 0.5,
CORNER_RADIUS 0.06 (6% of the card)
- CAMERA.zoom 1 (raise it to show the composition larger)
- MODIFIERS: easeId "linear", reverse, softStop (hold on each card, softStopHold 0.4)
- Drag: DRAG_LOOPS_PER_WIDTH 0.37, FLING_FRICTION 0.92 per 60 fps frame, MAX_FLING 1.5 loops/s
- Frame aspect clamped between 3:5 (tall helix on phones) and 16:9
Based on the "Spiral Helix" scene from the Sequen app (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>3D Spiral</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
.stage {
position: fixed;
inset: 0;
cursor: grab;
touch-action: none; /* drag scrubs the loop */
-webkit-user-select: none;
user-select: none;
}
.stage.is-dragging { cursor: grabbing; }
.canvas { display: block; width: 100%; height: 100%; }
/* Hairline loader: fills while the photos load, then fades out. */
.loader {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: #fff;
transition: opacity 0.6s ease;
}
.loader.is-done { opacity: 0; pointer-events: none; }
.loader-track { width: 160px; height: 1px; overflow: hidden; background: #e6e6e6; }
.loader-bar {
display: block;
height: 100%;
background: #000;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
</style>
</head>
<body>
<div class="stage" data-stage>
<canvas class="canvas" data-canvas role="img" aria-label="Twenty photographs climbing a two-turn helix in perspective"></canvas>
</div>
<div class="loader" data-loader aria-hidden="true">
<div class="loader-track"><span class="loader-bar" data-loader-bar></span></div>
</div>
<script>
(() => {
"use strict";
/* Scene ---------------------------------------------------------------- */
const DURATION = 12; // seconds per loop
const COUNT = 20; // cards
const PARAMS = { turns: 2, height: 100, wobble: 20 }; // coil turns, climb height (0–100), coil wobble (0–100)
const CAMERA = { panX: 0, panY: 0, zoom: 1 };
// Global modifiers: cycles → motion easing → direction → soft stop.
// Set softStop: true for a variant that holds on each card.
const MODIFIERS = {
cycles: 1,
easeId: "linear",
reverse: false,
spin: false,
fade: false,
sizeFactor: 1,
softStop: false,
softStopHold: 0.4,
softStopEaseId: "ease-in-out",
};
// Photos: swap these picsum.photos placeholders for your own (3:4 portraits; each is
// cropped to a centred square). {w}×{h} is filled in with a size that stays sharp.
const PHOTOS = Array.from({ length: COUNT }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/{w}/{h}`);
/* Stage and interaction ------------------------------------------------- */
const MIN_ASPECT = 0.6; // narrowest frame (3:5, a tall helix on phones)
const MAX_ASPECT = 16 / 9; // widest frame
const MAX_CARD_SCALE = 0.65; // largest card scale in this scene (picks the photo size)
const MAX_DPR = 2;
const DRAG_LOOPS_PER_WIDTH = 0.37; // ≈ the front cards follow the finger sideways
const DRAG_AXIS = { x: 1, y: -1 }; // drag right or up → loop forward (cards climb)
const REDUCED_MOTION_T = 0; // still frame
const FLING_FRICTION = 0.92; // release inertia, velocity kept per 60 fps frame
const MAX_FLING = 1.5; // loops per second
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Math helpers ----------------------------------------------------------- */
const PI = Math.PI;
const TAU = Math.PI * 2;
const mod = (value, modulo) => ((value % modulo) + modulo) % modulo;
const lerp = (from, to, mix) => from + (to - from) * mix;
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const wave = (phase) => 0.5 - 0.5 * Math.cos(TAU * mod(phase, 1));
/* Easing: cubic bezier with two handles, solved by bisection -------------- */
const EASINGS = {
linear: { h1x: 0, h1y: 0, h2x: 1, h2y: 1 },
"ease-in-out": { h1x: 0.42, h1y: 0, h2x: 0.58, h2y: 1 },
};
function sampleBezierAxis(handle1, handle2, t) {
const inverse = 1 - t;
return 3 * inverse * inverse * t * handle1 + 3 * inverse * t * t * handle2 + t * t * t;
}
function ease(easingId, x) {
const handles = EASINGS[easingId] || EASINGS.linear;
const clampedX = clamp(x, 0, 1);
if (clampedX === 0 || clampedX === 1) return clampedX;
let lower = 0;
let upper = 1;
let t = clampedX;
for (let iteration = 0; iteration < 24; iteration += 1) {
const sampledX = sampleBezierAxis(handles.h1x, handles.h2x, t);
if (Math.abs(sampledX - clampedX) < 1e-6) break;
if (sampledX < clampedX) lower = t;
else upper = t;
t = (lower + upper) / 2;
}
return sampleBezierAxis(handles.h1y, handles.h2y, t);
}
/* Soft stop: remaps the phase into `stops` evenly spaced holds ------------ */
function softStopPhase(phase, stops, hold, easingId) {
const wholeStops = Math.round(stops);
if (wholeStops < 1 || hold <= 0) return phase;
const clampedHold = clamp(hold, 0, 0.9);
const scaled = mod(phase, 1) * wholeStops;
const index = Math.floor(scaled);
const local = scaled - index;
const moved = local <= clampedHold ? 0 : clamp(ease(easingId, (local - clampedHold) / (1 - clampedHold)), 0, 1);
return (index + moved) / wholeStops;
}
/* Projection --------------------------------------------------------------
Scene space: x / y in [-1, 1] around the frame centre, z in [-1, 1] with +z toward
the viewer. The virtual camera sits at z = CAMERA_DISTANCE looking at the origin. */
const CAMERA_DISTANCE = 2.6;
function project3d(x, y, z) {
const distance = Math.max(0.2, CAMERA_DISTANCE - z);
const perspective = CAMERA_DISTANCE / distance;
return {
depth: z,
scale: perspective,
x: 0.5 + x * 0.5 * perspective * 0.62,
y: 0.5 + y * 0.5 * perspective * 0.62,
};
}
// Depth cue: nearer cards brighter; `strength` in [0, 1].
function depthOpacity(z, strength) {
const nearness = (z + 1) / 2;
return 1 - strength * (1 - nearness) * 0.85;
}
/* The scene: one card at loop time t, offset u = i / count ---------------- */
// Each card climbs the helix from bottom to top over one loop while circling the axis
// `turns` times. Returns x / y in normalised frame space (0..1), scale and opacity.
const EDGE_FADE = 0.18; // share of the climb spent fading in (bottom) and out (top)
// Smootherstep on 0..1 (zero slope and curvature at both ends: no visible start or stop).
function smootherstep(value) {
const x = clamp(value, 0, 1);
return x * x * x * (x * (x * 6 - 15) + 10);
}
function evaluate(t, u) {
const turns = Math.max(1, Math.round(PARAMS.turns));
const height = PARAMS.height / 100;
const wobble = PARAMS.wobble / 100;
const climb = mod(t + u, 1);
const angle = TAU * turns * climb;
const coil = 0.55 + wobble * 0.18 * Math.sin(TAU * (3 * climb + t));
const x3 = coil * Math.sin(angle);
const z3 = coil * Math.cos(angle);
const y3 = lerp(height * 0.9, -height * 0.9, climb); // canvas y points down: climb goes up
const projected = project3d(x3, y3, z3);
// Full 0 → 1 → 0 fade over the first and last EDGE_FADE of the climb.
const rim = smootherstep(climb / EDGE_FADE) * smootherstep((1 - climb) / EDGE_FADE);
return {
opacity: depthOpacity(z3 / 0.75, 0.7) * rim,
rotation: 0,
scale: projected.scale * 0.5,
squeezeX: 1,
x: projected.x,
y: projected.y,
};
}
function resolveCardTransform(t, index, count) {
const cycled = mod(t * MODIFIERS.cycles, 1);
const eased = clamp(ease(MODIFIERS.easeId, cycled), 0, 1);
const reversed = MODIFIERS.reverse ? mod(1 - eased, 1) : eased;
const effectiveT = MODIFIERS.softStop
? softStopPhase(reversed, count, MODIFIERS.softStopHold, MODIFIERS.softStopEaseId)
: reversed;
const transform = evaluate(effectiveT, index / count);
if (MODIFIERS.spin) transform.rotation += TAU * effectiveT;
if (MODIFIERS.fade) transform.opacity *= lerp(0.35, 1, wave(effectiveT + index / count));
transform.scale *= MODIFIERS.sizeFactor;
return transform;
}
/* Canvas 2D renderer -------------------------------------------------------
Square cards of min(w, h) × BASE_SIZE × scale, cover-cropped, rounded corners,
drawn back-to-front by ascending scale, with globalAlpha opacity. */
const BASE_SIZE = 0.26;
const CORNER_RADIUS = 0.06;
const MIN_SQUEEZE = 0.02; // cards thinner than this are skipped (edge-on)
function traceRoundedRect(context, x, y, width, height, radius) {
context.beginPath();
context.moveTo(x + radius, y);
context.arcTo(x + width, y, x + width, y + height, radius);
context.arcTo(x + width, y + height, x, y + height, radius);
context.arcTo(x, y + height, x, y, radius);
context.arcTo(x, y, x + width, y, radius);
context.closePath();
}
function drawFrame(context, frame, cards, t) {
const { width, height } = frame;
const slots = [];
for (let index = 0; index < COUNT; index += 1) {
slots.push({ card: cards[index % cards.length], transform: resolveCardTransform(t, index, COUNT) });
}
slots.sort((left, right) => left.transform.scale - right.transform.scale);
// Camera: zoom and pan about the frame centre.
context.save();
context.translate(frame.x + width / 2, frame.y + height / 2);
context.scale(CAMERA.zoom, CAMERA.zoom);
context.translate(-frame.x - width / 2 - CAMERA.panX * width, -frame.y - height / 2 - CAMERA.panY * height);
const baseSize = Math.min(width, height) * BASE_SIZE;
for (const { card, transform } of slots) {
const size = baseSize * transform.scale;
const squeezeX = transform.squeezeX;
if (Math.abs(squeezeX) < MIN_SQUEEZE) continue;
context.save();
context.globalAlpha = clamp(transform.opacity, 0, 1);
context.translate(frame.x + transform.x * width, frame.y + transform.y * height);
context.rotate(transform.rotation);
if (squeezeX !== 1) context.scale(squeezeX, 1);
traceRoundedRect(context, -size / 2, -size / 2, size, size, size * CORNER_RADIUS);
context.clip();
context.drawImage(card, -size / 2, -size / 2, size, size);
context.restore();
}
context.restore();
}
/* Page: canvas, images, loop clock, drag and scroll ------------------------ */
const stage = document.querySelector("[data-stage]");
const canvas = document.querySelector("[data-canvas]");
const context = canvas.getContext("2d");
const loader = document.querySelector("[data-loader]");
const loaderBar = document.querySelector("[data-loader-bar]");
const frame = { x: 0, y: 0, width: 1, height: 1 };
let cssWidth = 1;
let cssHeight = 1;
let dpr = 1;
// The frame fills the viewport, with its aspect clamped to [MIN_ASPECT, MAX_ASPECT].
function layout() {
cssWidth = stage.clientWidth;
cssHeight = stage.clientHeight;
dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
const aspect = clamp(cssWidth / cssHeight, MIN_ASPECT, MAX_ASPECT);
frame.width = Math.min(cssWidth, cssHeight * aspect);
frame.height = frame.width / aspect;
frame.x = (cssWidth - frame.width) / 2;
frame.y = (cssHeight - frame.height) / 2;
}
// Pick the lightest photo size that stays sharp at the largest on-screen card.
function photoSize() {
const aspect = clamp(window.innerWidth / window.innerHeight, MIN_ASPECT, MAX_ASPECT);
const w = Math.min(window.innerWidth, window.innerHeight * aspect);
const largestCard = Math.min(w, w / aspect) * BASE_SIZE * MAX_CARD_SCALE * CAMERA.zoom;
const needed = largestCard * Math.min(MAX_DPR, window.devicePixelRatio || 1);
if (needed <= 340) return { w: 360, h: 480 };
if (needed <= 580) return { w: 600, h: 800 };
return { w: 1200, h: 1600 };
}
// Decode each photo once into an offscreen canvas, cover-cropped to a square.
function rasterize(image) {
const edge = Math.min(image.naturalWidth, image.naturalHeight);
const raster = document.createElement("canvas");
raster.width = edge;
raster.height = edge;
raster.getContext("2d").drawImage(
image,
(image.naturalWidth - edge) / 2, (image.naturalHeight - edge) / 2, edge, edge,
0, 0, edge, edge,
);
return raster;
}
function loadCards() {
const size = photoSize();
let loaded = 0;
return Promise.all(
PHOTOS.map((src) => new Promise((resolve) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = () => {
loaded += 1;
loaderBar.style.transform = `scaleX(${loaded / PHOTOS.length})`;
resolve(rasterize(image));
};
image.onerror = () => resolve(null);
image.src = src.replace("{w}", size.w).replace("{h}", size.h);
})),
).then((cards) => cards.filter(Boolean));
}
// One loop clock: phase advances 1 per DURATION seconds and wraps, so t = 1 equals
// t = 0 (a seamless forward loop). Dragging adds to the same phase.
let phase = reducedMotion ? REDUCED_MOTION_T : 0;
let velocity = 0;
let dragging = false;
let pointerId = null;
let lastX = 0;
let lastY = 0;
let lastMoveTime = 0;
let lastFrameTime = 0;
let drawnPhase = NaN;
let needsDraw = true;
let cards = [];
function render() {
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, cssWidth, cssHeight);
context.imageSmoothingQuality = "high";
drawFrame(context, frame, cards, mod(phase, 1));
drawnPhase = phase;
needsDraw = false;
}
function tick(now) {
const dt = lastFrameTime ? Math.min(0.1, (now - lastFrameTime) / 1000) : 0;
lastFrameTime = now;
if (!dragging) {
if (!reducedMotion) phase += dt / DURATION;
if (velocity !== 0) {
phase += velocity * dt;
velocity *= Math.pow(FLING_FRICTION, dt * 60);
if (Math.abs(velocity) < 1e-4) velocity = 0;
}
}
if (needsDraw || phase !== drawnPhase) render();
requestAnimationFrame(tick);
}
function onPointerDown(event) {
if (pointerId !== null) return;
pointerId = event.pointerId;
dragging = true;
velocity = 0;
lastX = event.clientX;
lastY = event.clientY;
lastMoveTime = performance.now();
stage.setPointerCapture(pointerId);
stage.classList.add("is-dragging");
}
function onPointerMove(event) {
if (!dragging || event.pointerId !== pointerId) return;
const now = performance.now();
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
const delta = ((dx * DRAG_AXIS.x + dy * DRAG_AXIS.y) / frame.width) * DRAG_LOOPS_PER_WIDTH;
const dt = Math.max(0.001, (now - lastMoveTime) / 1000);
phase += delta;
velocity = clamp(lerp(velocity, delta / dt, 0.4), -MAX_FLING, MAX_FLING);
lastX = event.clientX;
lastY = event.clientY;
lastMoveTime = now;
}
function onPointerUp(event) {
if (event.pointerId !== pointerId) return;
dragging = false;
pointerId = null;
stage.classList.remove("is-dragging");
// No fling after a pause at the end of the drag, and none for reduced motion.
if (reducedMotion || performance.now() - lastMoveTime > 80) velocity = 0;
}
stage.addEventListener("pointerdown", onPointerDown);
stage.addEventListener("pointermove", onPointerMove);
stage.addEventListener("pointerup", onPointerUp);
stage.addEventListener("pointercancel", onPointerUp);
window.addEventListener("resize", () => {
layout();
needsDraw = true;
});
layout();
loadCards().then((loadedCards) => {
cards = loadedCards;
if (!cards.length) return;
needsDraw = true;
requestAnimationFrame(tick);
loader.classList.add("is-done");
loader.addEventListener("transitionend", () => loader.remove(), { once: true });
});
})();
</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>
원본 구현 프롬프트
# 3D Spiral: photo cards climbing a coiled helix in perspective **Goal:** build a full-screen, endlessly looping helix of twenty square photo cards. Each card rises from the bottom to the top while circling the axis twice, fading in at the bottom and out at the top. It loops on its own and can be scrubbed by dragging. ## Visual behaviour 1. A white, full-viewport canvas. The page itself never scrolls. Everything is drawn on one Canvas 2D with a simple perspective projection (no WebGL needed). 2. **Twenty square cards** (photos cover-cropped to a centred square), corner radius 6% of the card size, no shadows. Card `i` has offset `u = i / 20`. 3. **The loop.** A single phase `t` runs from 0 to 1 every **12 seconds**, linearly, and wraps (seamless). For each card: - `climb = (t + u) mod 1`: 0 at the bottom, 1 at the top. Every card climbs the full height once per loop, and a new card enters at the bottom every 0.6 s. - `angle = 2π · 2 · climb`: two full turns around the axis on the way up. - Coil radius with a gentle wobble: `coil = 0.55 + 0.036 · sin(2π · (3 · climb + t))`. 4. **3D position** (scene units, +z toward the viewer): - `x3 = coil · sin(angle)`, `z3 = coil · cos(angle)` - `y3 = lerp(0.9, −0.9, climb)` (screen y points down, so the card moves up as it climbs) 5. **Projection.** Virtual camera at z = 2.6: `p = 2.6 / max(0.2, 2.6 − z3)`. Normalised screen position (0..1): `x = 0.5 + x3 · 0.31 · p`, `y = 0.5 + y3 · 0.31 · p`. 6. **Scale:** `p · 0.5`. Card size in px = `min(frameWidth, frameHeight) · 0.26 · scale`. Cards always face the viewer (no squeeze or rotation). 7. **Opacity** = depth dimming × edge fade: - Depth: `1 − 0.7 · (1 − (z3 / 0.75 + 1) / 2) · 0.85` (cards behind the axis are dimmer). - Edge fade: `smootherstep(climb / 0.18) · smootherstep((1 − climb) / 0.18)`, with `smootherstep(x) = x³(x(6x − 15) + 10)` on x clamped to 0..1. So each card fades fully in from 0 over the first 18% of its climb and fully out over the last 18%, with no visible start or stop. 8. **Draw order:** sort by scale, ascending, every frame (nearer cards on top). 9. Direction: cards at the front of the helix move to the right. 10. **Drag to scrub.** Pointer down starts a drag (cursor `grab` → `grabbing`). Both horizontal and vertical movement scrub: `Δt = (dx − dy) / frameWidth · 0.37` (drag right or up = forward, so the cards climb). Autoplay pauses during the drag. 11. **Release inertia.** Velocity = `lerp(velocity, Δt / dt, 0.4)`, clamped to ±1.5 loops/s. On release it keeps adding `velocity · dt`, decaying by `0.92` per 60fps frame. No fling after a pause of more than 80 ms before release. Autoplay continues alongside. ## Frame The normalised frame fills the viewport with its aspect clamped between 3:5 and 16:9, centred: `aspect = clamp(vw / vh, 0.6, 16/9)`, `frameWidth = min(vw, vh · aspect)`, `frameHeight = frameWidth / aspect`. So phones get a tall helix. Canvas resolution × devicePixelRatio (max 2). ## Exact values | What | Value | |---|---| | Loop | 12 s, linear, seamless | | Cards | 20, square, radius 6% of size | | Turns | 2 per climb | | Coil radius | 0.55 ± 0.036 wobble (3 waves per climb, drifting with t) | | Climb height | y from +0.9 to −0.9 scene units | | Camera distance | 2.6, projection factor 0.31 | | Scale | perspective × 0.5 | | Depth dimming | strength 0.7 | | Edge fade | first / last 18% of the climb, smootherstep, 0 → 1 → 0 | | Drag | 0.37 loops per frame width, axes x and −y | | Fling | 0.92 per 60fps frame, max 1.5 loops/s | ## Implementation notes - One `requestAnimationFrame` loop, delta capped at 0.1 s. Advance the phase by `dt / 12` when not dragging, add the fling, redraw only when the phase changed. - Per card: `save()`, `globalAlpha`, `translate(x, y)` in px, clip to a rounded square, `drawImage` the pre-cropped square photo, `restore()`. - Decode each photo once into an offscreen square canvas. Show a thin loading bar until the photos are ready. ## Libraries None: plain JavaScript and Canvas 2D. A DOM version (absolutely positioned cards with `transform: translate() scale()`, `opacity` and `z-index` set per frame) works the same way. ## Responsive and reduced motion - The normalised frame keeps the helix proportions on every screen; narrow screens get a tall 3:5 frame. `touch-action: none` on the stage so drags scrub instead of scrolling. - `prefers-reduced-motion: reduce`: no autoplay and no fling. Show a still frame (t = 0) that can still be dragged. ## Acceptance criteria - Twenty cards climb a two-turn helix in a seamless 12-second loop, nearer cards larger and brighter. - Cards fade in softly from nothing at the bottom and fade out completely at the top; nothing pops in or out. - The coil radius breathes slightly as the cards climb. - Nearer cards always overlap farther ones. - Dragging right or up scrubs the helix forward, left or down backward; a quick release glides on. - With reduced motion, the helix is still until dragged.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 4ddf9387c87fd1e7f8793019e97c67ae01893dc1d0f666223c3742d563ef7d35