모션·인터랙션 / Cadence
Calm Carousel
갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Calm Carousel
Eight square photo cards glide around a flat, tilted orbit in the middle of the screen,
growing and brightening as they pass the front and shrinking and fading toward the back.
One seamless forward 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.width 70 (orbit width), PARAMS.fade 60 (depth fade), PARAMS.tilt 35 (path tilt)
- Card size: BASE_SIZE 0.26 × min(frame width, height) × scale (0.58 back → 1.04 front),
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.6, 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 "Calm Carousel" 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>Calm Carousel</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 circling on a flat, tilted orbit"></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 = { width: 70, fade: 60, tilt: 35 }; // orbit width, depth fade, path tilt (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 = 1; // narrowest frame (portrait phones get a square frame)
const MAX_ASPECT = 16 / 9; // widest frame
const MAX_CARD_SCALE = 1.04; // largest card scale in this scene (picks the photo size)
const MAX_DPR = 2;
const DRAG_LOOPS_PER_WIDTH = 0.6; // ≈ 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;
}
/* The scene: one card at loop time t, offset u = i / count ---------------- */
// Returns x / y in normalised frame space (0..1), scale and opacity.
function evaluate(t, u) {
const width = PARAMS.width / 100;
const fade = PARAMS.fade / 100;
const tilt = PARAMS.tilt / 100;
const angle = TAU * (t + u);
const nearness = 0.5 + 0.5 * Math.cos(angle); // 1 at the front, 0 at the back
return {
opacity: lerp(1 - 0.8 * fade, 1, nearness),
rotation: 0,
scale: lerp(0.58, 1.04, nearness),
squeezeX: 1,
x: 0.5 + width * 0.38 * Math.sin(angle),
y: 0.5 + (0.08 + tilt * 0.1) * Math.cos(angle) + tilt * 0.04 * Math.sin(angle),
};
}
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 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>
원본 구현 프롬프트
# Calm Carousel: photos gliding around a flat, tilted orbit that speeds up when you scroll
**Goal:** build a full-screen, endlessly looping carousel where eight square photo cards circle a flat, tilted ellipse, growing and brightening as they pass the front. 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. All cards are drawn on one Canvas 2D.
2. **Eight square cards** (photos cover-cropped to a centred square), corner radius 6% of the card size, no shadows. They are spaced evenly around one orbit: card `i` has offset `u = i / 8`.
3. **The loop.** A single phase `t` runs from 0 to 1 every **6 seconds**, linearly, and wraps (t = 1 looks exactly like t = 0, so the loop is seamless). For each card, `angle = 2π · (t + u)` and `nearness = 0.5 + 0.5 · cos(angle)` (1 at the front, 0 at the back).
4. **Position** in a normalised frame (0..1 on both axes, see *Frame*):
- `x = 0.5 + 0.266 · sin(angle)`
- `y = 0.5 + 0.115 · cos(angle) + 0.014 · sin(angle)`
So the orbit is a wide, flat ellipse with a slight diagonal tilt. The front of the orbit is the lower edge. Front cards move to the right, back cards to the left.
5. **Scale:** `lerp(0.58, 1.04, nearness)`. Card size in px = `min(frameWidth, frameHeight) · 0.26 · scale`.
6. **Opacity:** `lerp(0.52, 1, nearness)` (the back cards fade to 52%).
7. **Draw order:** sort the cards by scale, ascending, every frame, so bigger (nearer) cards are drawn on top.
8. **Drag to scrub.** Pointer down on the stage starts a drag (cursor `grab` → `grabbing`). Horizontal movement adds to the phase: `Δt = dx / frameWidth · 0.6` (drag right = forward, so the front card roughly follows the finger). While dragging, the autoplay pauses.
9. **Release inertia.** Track velocity as `lerp(velocity, Δt / dt, 0.4)`, clamped to ±1.5 loops/s. On release it keeps adding `velocity · dt` and decays by a factor `0.92` per 60fps frame (`velocity *= 0.92^(dt·60)`). If the pointer was still for more than 80 ms before release, there is no fling. The normal autoplay pace continues alongside.
10. **Scroll to speed up.** Wheel and trackpad scrolling don't move the page. Instead they push a speed boost in the direction of the scroll (down = forward, up = backward):
- Each wheel event adds `delta · 0.004` loops/s to a boost target (use the larger of deltaY / deltaX; convert line and page delta modes to pixels: ×16 / ×frame height). Clamp the target to ±0.5 loops/s. Ignore wheel events with `ctrlKey` (pinch zoom).
- Every frame the target relaxes toward zero (`target *= 0.96^(dt·60)`) and the applied boost eases toward the target (`boost += (target − boost) · (1 − 0.9^(dt·60))`). Add `boost · dt` to the phase.
- So the loop smoothly speeds up to at most 0.5 + 1/6 ≈ 4× its normal pace (or runs backward when scrolling up), and settles back to normal without jumps when scrolling stops.
- On touch, a vertical swipe does the same: `−dy · 0.006` loops/s per pixel (swipe up = forward), applied even during the drag.
## Frame
The composition is laid out in a normalised frame that fills the viewport, with its aspect clamped between 1:1 and 16:9 and centred: `aspect = clamp(vw / vh, 1, 16/9)`, `frameWidth = min(vw, vh · aspect)`, `frameHeight = frameWidth / aspect`. Portrait phones get a square frame across the full width. Render the canvas at `devicePixelRatio` (capped at 2).
## Exact values
| What | Value |
|---|---|
| Loop | 6 s, linear, seamless |
| Cards | 8, square, radius 6% of size |
| Orbit | x radius 0.266, y radius 0.115 (+0.014 tilt term), centre 0.5 / 0.5 |
| Scale | 0.58 (back) → 1.04 (front) |
| Opacity | 0.52 (back) → 1 (front) |
| Card size | min(frame w, h) × 0.26 × scale |
| Drag | 0.6 loops per frame width |
| Fling | friction 0.92 per 60fps frame, max 1.5 loops/s, none after an 80 ms pause |
| Scroll boost | 0.004 loops/s per px (touch 0.006), max ±0.5 loops/s, target release 0.96, ease 0.1 (per 60fps frame) |
## Implementation notes
- One `requestAnimationFrame` loop. Delta time capped at 0.1 s. Advance the phase by `dt / 6` when not dragging, add the fling and the boost, and only redraw when the phase changed.
- Decode each photo once into an offscreen canvas, already cropped to a square, then `drawImage` that canvas each frame inside a rounded-rect clip with `globalAlpha` set to the opacity.
- Pick the image size from the largest on-screen card (× device pixel ratio) so the cards stay sharp without loading huge files.
- Show a thin loading bar until the photos are decoded, then fade it out.
## Libraries
None needed: plain JavaScript and Canvas 2D. (The same layout also works with absolutely positioned DOM elements, setting `transform: translate() scale()`, `opacity` and `z-index` per frame.)
## Responsive and reduced motion
- Works at any size thanks to the normalised frame. `touch-action: none` on the stage so a drag scrubs instead of scrolling the page.
- `prefers-reduced-motion: reduce`: no autoplay, no fling, no scroll boost. Show a still frame (t = 0, card 1 at the front) that can still be dragged.
## Acceptance criteria
- Eight cards circle a flat, slightly tilted ellipse in a 6-second loop with no visible seam.
- Cards grow and brighten toward the front (lower edge) and shrink and fade toward the back, and nearer cards always overlap farther ones.
- Dragging sideways scrubs the orbit; a quick release glides on and settles.
- Scrolling down speeds the loop up (scrolling up runs it backward), up to about 4× the normal pace, and it eases back to the normal speed when scrolling stops, with no jumps.
- With reduced motion, the carousel is still until dragged.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: ece48ed474db10cd510537421a820c01e8aa1672e774e438a1b5a8e6bbc44935