모션·인터랙션 / Cadence
Card Flip
3D · 갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Card Flip
A fanned deck of eight photos drawn on a Canvas 2D. The deck slides one card per 1/8 of
the loop; the card crossing the centre flips over (a horizontal squeeze through cos, the
back face is the mirrored photo), while the cards either side lean, sink, shrink and fade
with their distance from the front. One seamless six-second loop. Drag (or swipe)
sideways to move the deck, with a little release inertia; then the loop carries on.
Dependencies: none (Canvas 2D and plain JavaScript).
Tweak (constants at the top of the script):
- DURATION 6 s per loop, COUNT 8 cards
- PARAMS: gap 45 (stack spacing), front 60 (front card boost), lean 25 (tilt of side cards)
- CAMERA.zoom 1.8 (overall scale of the composition)
- BASE_SIZE 0.26 (card = min(frame w, h) × 0.26 × scale), CORNER_RADIUS 0.06 (× card size)
- DRAG_LOOPS_PER_WIDTH 1.74, FLING_FRICTION 0.92 per 60 fps frame, MAX_FLING 1.5 loops/s
- The frame fills the viewport with its aspect clamped between 1:1 and 16:9
From the Sequen app (own project): the card-flip scene maths and Sequen's Canvas 2D
card renderer, with its global modifiers at their defaults.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Card Flip</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 */
user-select: none;
-webkit-user-select: none;
}
.stage.is-dragging { cursor: grabbing; }
canvas { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="stage" data-stage>
<canvas data-canvas role="img" aria-label="A fanned deck of photos, each card flipping as it crosses the centre"></canvas>
</div>
<script>
(() => {
"use strict";
/* Scene constants ---------------------------------------------------- */
const DURATION = 6; // seconds per loop
const COUNT = 8; // cards
const PARAMS = { gap: 45, front: 60, lean: 25 }; // stack gap, front boost, flip lean
const CAMERA = { panX: 0, panY: 0, zoom: 1.8 }; // zoom and pan about the frame centre
const BASE_SIZE = 0.26; // card size = min(w, h) × BASE_SIZE × scale
const CORNER_RADIUS = 0.06; // × card size
const MIN_SQUEEZE = 0.02; // cards thinner than this are skipped (edge-on)
const MIN_ASPECT = 1; // narrowest frame (portrait phones get a square frame)
const MAX_ASPECT = 16 / 9; // widest frame
const MAX_DPR = 2;
const DRAG_LOOPS_PER_WIDTH = 1.74; // ≈ the deck follows the finger
const DRAG_AXIS = { x: -1, y: 0 }; // the deck moves left as the loop runs forward
const FLING_FRICTION = 0.92; // release inertia, velocity kept per 60 fps frame
const MAX_FLING = 1.5; // loops per second
const REDUCED_MOTION_T = 0.999 / COUNT; // still frame: the front card face-on
// Swap these placeholders for your own photos (they are cover-cropped to squares).
const PHOTOS = Array.from({ length: COUNT }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/900/1200`);
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Helpers ------------------------------------------------------------ */
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));
/* Scene: transform of card i at loop phase t (0..1), x/y in 0..1 frame space */
function evaluate(t, i, count) {
const gap = PARAMS.gap / 100;
const front = PARAMS.front / 100;
const lean = PARAMS.lean / 100;
// The deck advances one card per 1/count of the loop; the active card flips.
const step = mod(t * count, count);
const position = mod(i - step, count);
const offset = position <= count / 2 ? position : position - count;
const flipPhase = 1 - mod(step, 1);
const isFlipping = Math.abs(offset) < 1;
const flipAngle = isFlipping ? flipPhase * Math.PI : 0;
const squeeze = isFlipping ? Math.cos(flipAngle) : 1;
const activeness = Math.max(0, 1 - Math.abs(offset) / 2.5);
return {
opacity: lerp(0.3, 1, activeness),
rotation: lean * 0.25 * offset,
scale: (0.4 + front * 0.35) * lerp(0.55, 1, activeness),
squeezeX: Math.abs(squeeze) < 0.02 ? 0.02 : squeeze, // negative = mirrored back face
x: 0.5 + offset * gap * 0.16,
y: 0.5 + Math.abs(offset) * gap * 0.03,
};
}
/* Renderer: square cards, back to front by ascending scale ----------- */
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: evaluate(t, index, COUNT) });
}
slots.sort((a, b) => a.transform.scale - b.transform.scale);
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;
if (Math.abs(transform.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 (transform.squeezeX !== 1) context.scale(transform.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 ----------------------------- */
const stage = document.querySelector("[data-stage]");
const canvas = document.querySelector("[data-canvas]");
const context = canvas.getContext("2d");
const frame = { x: 0, y: 0, width: 1, height: 1 };
let cssWidth = 1;
let cssHeight = 1;
let dpr = 1;
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;
}
// 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() {
return Promise.all(
PHOTOS.map((src) => new Promise((resolve) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = () => resolve(rasterize(image));
image.onerror = () => resolve(null);
image.src = src;
})),
).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-only 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);
}
stage.addEventListener("pointerdown", (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");
});
stage.addEventListener("pointermove", (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("pointerup", onPointerUp);
stage.addEventListener("pointercancel", onPointerUp);
window.addEventListener("resize", () => {
layout();
needsDraw = true;
});
layout();
loadCards().then((loaded) => {
cards = loaded;
if (!cards.length) return;
needsDraw = true;
requestAnimationFrame(tick);
});
})();
</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>
원본 구현 프롬프트
# Card Flip: a fanned deck that slides one card per beat, flipping the centre card **Goal:** build a seamless, looping canvas animation of a fanned deck of eight square photo cards. The deck slides sideways one card per beat, and the card crossing the centre flips over like a playing card. Dragging sideways scrubs the loop. ## Visual behaviour 1. **One loop clock.** A phase `t` runs from 0 to 1 every 6 seconds and wraps (t = 1 looks exactly like t = 0). The deck advances one card per 1/8 of the loop (0.75 s per card), at constant (linear) speed. 2. **Per card** (index `i` of `N = 8`), at phase `t`: - `step = (t × N) mod N` - `position = (i − step) mod N` - `offset = position ≤ N/2 ? position : position − N` (a signed distance from the centre slot, −4…4) - **Flip:** only the card with `|offset| < 1` flips. `flipPhase = 1 − (step mod 1)`, `squeeze = cos(flipPhase × π)`. The card is drawn with `scaleX(squeeze)`; when squeeze is negative the photo is mirrored (its back face). Clamp `|squeeze|` to at least 0.02 and skip drawing below that (edge-on). - `activeness = max(0, 1 − |offset| / 2.5)` - `opacity = lerp(0.3, 1, activeness)` - `rotation = 0.25 × 0.25 × offset` radians (lean 25%) - `scale = (0.4 + 0.6 × 0.35) × lerp(0.55, 1, activeness)` (= 0.61 at the centre) - `x = 0.5 + offset × 0.45 × 0.16`, `y = 0.5 + |offset| × 0.45 × 0.03` (fractions of the frame) 3. **Drawing.** Each card is a square of `min(frameW, frameH) × 0.26 × scale`, photo cover-cropped to the square, corner radius 6% of the card size, no shadows. Draw back to front by ascending scale. For each card: translate to (x, y), rotate, scale X by squeeze, clip to the rounded rect, draw the image, with `globalAlpha = opacity`. 4. **Camera.** The whole composition is scaled 1.8× around the frame centre. 5. **Frame.** The canvas fills the viewport. The drawing frame is centred in it with its aspect clamped between 1:1 and 16:9 (portrait phones get a square frame, very wide screens a 16:9 one). Render at up to 2× device pixel ratio. 6. **Note:** the flip maths jump between front and mirrored face at offsets 1 and 0, so each photo briefly shows mirrored as it flips. That's intended. ## Drag - Horizontal drag adds to the phase: `phase += (−dx / frameWidth) × 1.74` (dragging left moves the deck forward, so it follows the finger). - While dragging, the clock pauses. Track a velocity: `velocity = clamp(lerp(velocity, delta / dt, 0.4), −1.5, 1.5)` loops per second. - On release, keep that velocity as inertia (`phase += velocity × dt`, `velocity × = 0.92^(dt × 60)` per frame, stop below 1e-4), unless the pointer rested for more than 80 ms before release. The clock resumes. ## Exact values | What | Value | |---|---| | Loop | 6 s, linear, 8 cards | | Stack gap | 0.45 → x step 0.072 of frame width, y drop 0.0135 per card | | Front boost | 0.6 → front scale 0.61 | | Lean | 0.0625 rad per card of offset | | Fade | opacity 0.3 → 1 over offsets 2.5 → 0 | | Card | min(w, h) × 0.26 × scale, radius 6% | | Camera zoom | 1.8 | | Frame aspect | clamped to 1:1 … 16:9 | | Drag | 1.74 loops per frame width; fling friction 0.92 per 60 fps frame, max 1.5 loops/s | ## Structure ```html <div class="stage"><canvas></canvas></div> ``` - `.stage`: fixed, full viewport, white background, `touch-action: none`, `cursor: grab`. - Load the 8 photos, rasterise each once into an offscreen square canvas (cover crop), then start the `requestAnimationFrame` loop. Redraw only when the phase changes. ## Libraries - None. Canvas 2D and plain JavaScript. (The same maths would also work with CSS transforms on 8 absolutely positioned elements, using `scaleX` for the squeeze and `z-index` from the scale.) ## Responsive and reduced motion - The frame adapts to the viewport (aspect clamp above); recompute on resize. - `prefers-reduced-motion: reduce`: no autoplay and no fling. Show a still frame with the front card face-on (phase = 0.999 / 8); dragging still moves the deck. ## Acceptance criteria - The deck loops forever with no visible seam: every 0.75 s the next card slides into the centre while flipping over. - Side cards lean, sit lower, shrink and fade with distance from the centre; the centre card is largest and fully opaque. - Cards are drawn in the right depth order (larger in front). - Dragging left moves the deck forward with the pointer; releasing a quick drag carries on briefly, then the loop resumes. - Works on any aspect ratio without stretching.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 61aa4f915c97c2b06fc34c5adbe23f4d28c4fc13448123f4759a30f98fcdc656