모션·인터랙션 / Cadence
3D Ring
3D · 갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
3D Ring
A ring of eight square photo cards turning in perspective. The ring leans slightly; the
cards stand radially, so they foreshorten as they swing to the sides (edge-on cards are
skipped), show their mirrored back faces behind, and dim with depth. One seamless loop
every six seconds, drawn on a single Canvas 2D. Drag sideways
to scrub the loop (with a little release inertia). Scrolling (wheel / trackpad, or a
vertical swipe on touch) speeds the loop up in the scroll's direction – down = forward,
up = backward – and it eases back to its normal pace when you stop.
Dependencies: none (plain JavaScript, Canvas 2D).
Tweak (constants at the top of the script):
- DURATION 6 s per loop, COUNT 8 cards
- PARAMS.spread 60 (ring radius), PARAMS.lean 35 (ring lean), PARAMS.facing 0
(0 = radial cards that turn edge-on, 100 = cards always face the viewer)
- Perspective: CAMERA_DISTANCE 2.6; depth dimming strength 0.75
- Card size: BASE_SIZE 0.26 × min(frame width, height) × perspective scale × 0.62,
CORNER_RADIUS 0.06 (6% of the card)
- CAMERA.zoom 1.8 (the whole composition shown at 180%)
- MODIFIERS: easeId "linear", reverse, softStop (hold on each card, softStopHold 0.4)
- Drag: DRAG_LOOPS_PER_WIDTH 0.66, FLING_FRICTION 0.92 per 60 fps frame, MAX_FLING 1.5 loops/s
- Scroll boost: SCROLL_BOOST_GAIN 0.004 loops/s per px, SWIPE_BOOST_GAIN 0.006,
SCROLL_BOOST_MAX 0.5 loops/s (up to 4× the base pace), SCROLL_BOOST_RELEASE 0.96,
SCROLL_BOOST_EASE 0.1 (both per 60 fps frame)
Based on the "Ring Orbit" 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 Ring</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="Eight photographs turning on a leaning ring 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 = 6; // seconds per loop
const COUNT = 8; // cards
const PARAMS = { spread: 60, lean: 35, facing: 0 }; // ring spread, ring lean, card facing (0–100)
const CAMERA = { panX: 0, panY: 0, zoom: 1.8 }; // shows the composition at 180%
// 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 = 1; // narrowest frame (portrait phones get a square frame)
const MAX_ASPECT = 16 / 9; // widest frame
const MAX_CARD_SCALE = 0.81; // largest card scale in this scene (picks the photo size)
const MAX_DPR = 2;
const DRAG_LOOPS_PER_WIDTH = 0.66; // ≈ the front card follows the finger
const DRAG_AXIS = { x: 1, y: 0 }; // drag right → loop forward
const REDUCED_MOTION_T = 0; // still frame: card 1 at the front
const FLING_FRICTION = 0.92; // release inertia, velocity kept per 60 fps frame
const MAX_FLING = 1.5; // loops per second
// Scroll to speed up: each wheel / swipe delta pushes a boost target (loops per second,
// signed); the target relaxes toward 0 and the applied boost eases after it.
const SCROLL_BOOST_GAIN = 0.004; // loops/s added per pixel of wheel / trackpad scroll
const SWIPE_BOOST_GAIN = 0.006; // loops/s added per pixel of vertical touch swipe
const SCROLL_BOOST_MAX = 0.5; // at most ±0.5 loops/s extra (base pace 1/6 → up to 4×)
const SCROLL_BOOST_RELEASE = 0.96; // share of the target kept per 60 fps frame
const SCROLL_BOOST_EASE = 0.1; // how far the applied boost moves toward the target per frame
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 ---------------- */
// A ring of radial cards turning in perspective. Returns x / y in normalised frame
// space (0..1), scale, opacity and a horizontal squeeze (negative = mirrored back face).
function evaluate(t, u) {
const spread = PARAMS.spread / 100;
const lean = PARAMS.lean / 100;
const facing = PARAMS.facing / 100;
const angle = TAU * (t + u);
const x3 = spread * Math.sin(angle);
const z3 = spread * Math.cos(angle);
const y3 = -lean * 0.35 * Math.cos(angle);
const projected = project3d(x3, y3, z3);
// Radial cards foreshorten as they turn away; face-camera cards (facing 100) stay flat.
const radialSqueeze = clamp(Math.cos(angle), -1, 1);
return {
opacity: depthOpacity(z3 / Math.max(0.2, spread), 0.75),
rotation: 0,
scale: projected.scale * 0.62,
squeezeX: lerp(radialSqueeze, 1, facing),
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 and a horizontal
squeeze (negative = mirrored back face). */
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 and the scroll boost add to the same phase.
let phase = reducedMotion ? REDUCED_MOTION_T : 0;
let velocity = 0;
let boostTarget = 0; // boost the wheel / swipe asks for (loops per second)
let boost = 0; // eased boost actually applied
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;
}
}
// Scroll boost: applied even mid-drag, so a vertical touch swipe spins the loop.
if (!reducedMotion && (boostTarget !== 0 || boost !== 0)) {
boostTarget *= Math.pow(SCROLL_BOOST_RELEASE, dt * 60);
boost += (boostTarget - boost) * (1 - Math.pow(1 - SCROLL_BOOST_EASE, dt * 60));
if (Math.abs(boostTarget) < 1e-4) boostTarget = 0;
if (boostTarget === 0 && Math.abs(boost) < 1e-4) boost = 0;
phase += boost * dt;
}
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);
// Touch: a vertical swipe speeds the loop up (swipe up = forward, like scrolling down).
if (event.pointerType === "touch") addBoost(-dy * SWIPE_BOOST_GAIN);
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;
}
function addBoost(amount) {
if (reducedMotion) return;
boostTarget = clamp(boostTarget + amount, -SCROLL_BOOST_MAX, SCROLL_BOOST_MAX);
}
// The page doesn't scroll, so wheel / trackpad scrolling is read directly.
function onWheel(event) {
if (event.ctrlKey) return; // pinch-zoom
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? frame.height : 1;
const delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
addBoost(delta * unit * SCROLL_BOOST_GAIN);
}
window.addEventListener("wheel", onWheel, { passive: true });
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 Ring: photo cards turning on a leaning ring in perspective, faster when you scroll
**Goal:** build a full-screen, endlessly looping ring of eight square photo cards that turns in perspective. The cards stand radially, so they foreshorten and flip as they go round. It loops on its own; dragging scrubs it and scrolling speeds it up.
## Visual behaviour
1. A white, full-viewport canvas. The page itself never scrolls. Everything is drawn on one Canvas 2D (a fake 3D projection, no WebGL needed).
2. **Eight square cards** (photos cover-cropped to a centred square), corner radius 6% of the card size, no shadows. Card `i` sits at offset `u = i / 8` around the ring.
3. **The loop.** A single phase `t` runs from 0 to 1 every **6 seconds**, linearly, and wraps (seamless). For each card, `angle = 2π · (t + u)`.
4. **3D position** (scene units, +z toward the viewer):
- `x3 = 0.6 · sin(angle)`, `z3 = 0.6 · cos(angle)` (ring radius 0.6)
- `y3 = −0.1225 · cos(angle)`: the ring leans, so front cards sit a little higher on screen than back cards.
5. **Projection.** A virtual camera at z = 2.6: `p = 2.6 / max(0.2, 2.6 − z3)`. Screen position in a normalised frame (0..1): `x = 0.5 + x3 · 0.31 · p`, `y = 0.5 + y3 · 0.31 · p`.
6. **Scale:** `p · 0.62` (about 0.81 at the front, 0.50 at the back). Card size in px = `min(frameWidth, frameHeight) · 0.26 · scale`.
7. **Foreshortening:** squeeze each card horizontally by `cos(angle)`: full width at the front, edge-on at the sides, and negative (mirrored, showing the back face) behind. Skip cards whose squeeze is under 0.02 in absolute value.
8. **Depth dimming:** `opacity = 1 − 0.75 · (1 − (cos(angle) + 1) / 2) · 0.85`: 1 at the front, about 0.36 at the back.
9. **Draw order:** sort by scale, ascending, every frame, so nearer cards are drawn on top.
10. **Camera zoom:** the whole composition is scaled 1.8× around the frame centre, so the ring fills the screen.
11. Direction: front cards move to the right, back cards to the left.
12. **Drag to scrub.** Pointer down starts a drag (cursor `grab` → `grabbing`). Horizontal movement adds to the phase: `Δt = dx / frameWidth · 0.66` (drag right = forward). Autoplay pauses during the drag.
13. **Release inertia.** Velocity = `lerp(velocity, Δt / dt, 0.4)`, clamped to ±1.5 loops/s. On release it keeps adding `velocity · dt` and decays by `0.92` per 60fps frame. No fling if the pointer was still for more than 80 ms before release. Autoplay continues alongside.
14. **Scroll to speed up.** Wheel and trackpad scrolling push a speed boost in the scroll direction (down = forward, up = backward):
- Each wheel event adds `delta · 0.004` loops/s to a boost target (the larger of deltaY / deltaX; line and page delta modes converted to px: ×16 / ×frame height). Clamp the target to ±0.5 loops/s. Ignore `ctrlKey` wheel events (pinch zoom).
- Every frame: `target *= 0.96^(dt·60)`, `boost += (target − boost) · (1 − 0.9^(dt·60))`, `phase += boost · dt`.
- The ring speeds up smoothly to at most ≈ 4× its normal pace and eases back to normal when scrolling stops. No jumps.
- Touch: a vertical swipe adds `−dy · 0.006` loops/s per pixel (swipe up = forward), even during the drag.
## Frame
The normalised frame fills the viewport with its aspect clamped between 1:1 and 16:9, centred: `aspect = clamp(vw / vh, 1, 16/9)`, `frameWidth = min(vw, vh · aspect)`, `frameHeight = frameWidth / aspect`. Canvas resolution × devicePixelRatio (max 2).
## Exact values
| What | Value |
|---|---|
| Loop | 6 s, linear, seamless |
| Cards | 8, square, radius 6% of size |
| Ring | radius 0.6, lean term 0.1225 |
| Camera distance | 2.6, projection factor 0.31 |
| Scale | perspective × 0.62 |
| Squeeze | cos(angle), skip under 0.02 |
| Opacity | 1 → ≈ 0.36 (depth strength 0.75) |
| Zoom | 1.8 about the frame centre |
| Drag | 0.66 loops per frame width |
| Fling | 0.92 per 60fps frame, max 1.5 loops/s |
| Scroll boost | 0.004 loops/s per px (touch 0.006), max ±0.5, release 0.96, ease 0.1 |
## Implementation notes
- One `requestAnimationFrame` loop, delta capped at 0.1 s. Advance the phase by `dt / 6` when not dragging, add fling and boost, redraw only when the phase changed.
- Per card: `save()`, set `globalAlpha`, `translate(x, y)` in px, `scale(squeeze, 1)`, clip to a rounded square, `drawImage` the pre-cropped square photo, `restore()`. Apply the 1.8× zoom once around the frame centre before drawing the cards.
- 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 with CSS `transform: translate() scale(squeeze, 1)` per card works too; a real CSS 3D (`rotateY`) ring is an alternative, but match the projection numbers above for the same look.
## Responsive and reduced motion
- The normalised frame keeps the composition the same at every size; phones get a square frame. `touch-action: none` on the stage.
- `prefers-reduced-motion: reduce`: no autoplay, no fling, no scroll boost. A still frame (card 1 at the front) that can still be dragged.
## Acceptance criteria
- Eight cards turn on a leaning ring in a seamless 6-second loop, large and bright at the front, small and dim at the back.
- Cards narrow to edge-on at the sides and show mirrored backs behind; edge-on cards disappear cleanly.
- Nearer cards always overlap farther ones.
- Dragging scrubs the ring; a quick release glides on.
- Scrolling down speeds the ring up (up runs it backward), to at most about 4× the normal pace, then it eases back to normal speed when scrolling stops.
- With reduced motion, the ring is still until dragged.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: a16eec4e334e7b85050e235edd6bfd24f3af6cc423c1da2bec44ff29e7dee626