모션·인터랙션 / Cadence
Cover Flow
갤러리 · 3D · 내비게이션 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Cover Flow
Photos fanned out in 3D like record sleeves, in an endless loop. Every sleeve is placed
from one number, its distance to the current position: zero lies flat in the middle, the
rest rotate away (55°), move back in depth and shift sideways, with an extra gap around
the centre one. Drag moves the position continuously and snaps to the nearest sleeve on
release (nudged in the direction of the flick); the wheel, the arrow keys and a click on
a side sleeve step to a new sleeve (0.8s, power3.out). The caption shows the centre
sleeve's title. Sleeves near the wrap point fade out, so the loop never shows a jump.
Dependencies: GSAP 3.15 + Observer, from jsDelivr.
Tweak (CONFIG at the top of the script):
- spacing 0.42 (sideways shift per sleeve, × sleeve width), centreGap 0.9
- angle 55° (side sleeves), depth 260px (+ DEPTH_PER_SLEEVE 30px per sleeve of distance)
- dragSpeed 0.004 sleeves per px, FLICK 0.15, snapDuration 0.8s, ease "power3.out"
- Sleeve size: --sleeve-w in the CSS (3:4), perspective 1400px on .cf
From The Animations Index, 32 Cover Flow (own project).
GSAP is free under the GSAP standard licence (https://gsap.com).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cover Flow</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #000; }
.stage {
--sleeve-w: min(clamp(180px, 30vw, 360px), (100svh - 240px) * 0.75);
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(24px, 4svh, 40px);
overflow: hidden;
touch-action: none; /* horizontal drags never scroll the page */
user-select: none;
-webkit-user-select: none;
cursor: grab;
}
.stage:active { cursor: grabbing; }
.cf {
position: relative;
width: 100%;
height: calc(var(--sleeve-w) * 4 / 3);
perspective: 1400px;
}
.cf-sleeve {
position: absolute;
top: 0;
left: 50%;
width: var(--sleeve-w);
aspect-ratio: 3 / 4;
margin-left: calc(var(--sleeve-w) / -2);
transform-style: preserve-3d;
will-change: transform;
background: #efefef;
border-radius: 6px;
overflow: hidden;
}
.cf-sleeve img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
-webkit-user-drag: none;
}
.cf-caption {
min-height: 1.5em;
margin: 0;
text-align: center;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #8e8e8e;
}
</style>
</head>
<body>
<!-- Swap the picsum.photos placeholders for your own 3:4 photos and titles (any number works). -->
<div class="stage" id="stage">
<div class="cf" id="flow" aria-label="Photos, drag, scroll or use the arrow keys">
<div class="cf-sleeve" data-title="Photo One"><img src="https://picsum.photos/seed/cadence-1/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Two"><img src="https://picsum.photos/seed/cadence-2/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Three"><img src="https://picsum.photos/seed/cadence-3/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Four"><img src="https://picsum.photos/seed/cadence-4/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Five"><img src="https://picsum.photos/seed/cadence-5/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Six"><img src="https://picsum.photos/seed/cadence-6/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Seven"><img src="https://picsum.photos/seed/cadence-7/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Eight"><img src="https://picsum.photos/seed/cadence-8/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Nine"><img src="https://picsum.photos/seed/cadence-9/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Ten"><img src="https://picsum.photos/seed/cadence-10/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Eleven"><img src="https://picsum.photos/seed/cadence-11/900/1200" alt="" draggable="false"></div>
<div class="cf-sleeve" data-title="Photo Twelve"><img src="https://picsum.photos/seed/cadence-12/900/1200" alt="" draggable="false"></div>
</div>
<p class="cf-caption" id="caption" aria-live="polite"></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Observer.min.js"></script>
<script>
gsap.registerPlugin(Observer);
const CONFIG = {
spacing: 0.42, // sideways shift per sleeve, as a fraction of sleeve width
centreGap: 0.9, // extra shift for the first neighbours so the centre stands alone
angle: 55, // degrees of rotation for side sleeves
depth: 260, // px pushed back for side sleeves
dragSpeed: 0.004, // sleeves per px of drag
snapDuration: 0.8, // s
};
const DEPTH_PER_SLEEVE = 30; // px further back per sleeve of distance
const FLICK = 0.15; // share of the release velocity added to the snap
const EDGE_FADE = 1; // sleeves fade out over the last sleeve before the wrap point
const stage = document.getElementById("stage");
const caption = document.getElementById("caption");
const sleeves = Array.from(document.querySelectorAll(".cf-sleeve"));
const COUNT = sleeves.length;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Wrap any number into 0..COUNT, and a distance into -COUNT/2..COUNT/2 (endless loop)
const wrapIndex = (value) => ((value % COUNT) + COUNT) % COUNT;
const wrapDistance = (value) => wrapIndex(value + COUNT / 2) - COUNT / 2;
let position = 0; // current, fractional (and unbounded) index
const state = { value: 0 }; // tweened by GSAP
// Every sleeve is placed from its distance to the current position.
function layout() {
const width = sleeves[0].offsetWidth;
sleeves.forEach((el, i) => {
const d = wrapDistance(i - position); // distance from the centre, the short way round
const side = Math.sign(d);
const a = Math.min(Math.abs(d), 1); // 0 at the centre, 1 from the first neighbour on
const x = d * width * CONFIG.spacing + side * a * width * CONFIG.centreGap;
const rotate = -side * a * CONFIG.angle;
const z = -a * CONFIG.depth - Math.abs(d) * DEPTH_PER_SLEEVE;
el.style.transform = `translate3d(${x}px, 0, ${z}px) rotateY(${rotate}deg)`;
el.style.zIndex = String(100 - Math.round(Math.abs(d) * 10));
el.style.opacity = String(Math.min(1, Math.max(0, (COUNT / 2 - Math.abs(d)) / EDGE_FADE)));
});
const current = sleeves[wrapIndex(Math.round(position))];
if (caption.textContent !== current.dataset.title) caption.textContent = current.dataset.title;
}
function render() {
position = state.value;
layout();
}
function goTo(index) {
gsap.to(state, { value: index, duration: reduced ? 0 : CONFIG.snapDuration, ease: "power3.out", onUpdate: render, overwrite: true });
}
let lastDrag = 0;
sleeves.forEach((el, i) => {
el.addEventListener("click", () => {
if (Math.abs(lastDrag) < 4) goTo(Math.round(state.value + wrapDistance(i - state.value)));
});
});
Observer.create({
target: stage,
type: "pointer,touch,wheel",
dragMinimum: 3,
onPress: () => {
lastDrag = 0;
},
onDragStart: () => {
gsap.killTweensOf(state);
lastDrag = 0;
},
onDrag: (self) => {
lastDrag += Math.abs(self.deltaX);
state.value -= self.deltaX * CONFIG.dragSpeed;
render();
},
onDragEnd: (self) => {
// Snap, with a nudge in the direction of the flick.
const flick = -self.velocityX * CONFIG.dragSpeed * FLICK;
goTo(Math.round(state.value + flick));
},
onWheel: (self) => {
goTo(Math.round(state.value) + Math.sign(self.deltaY || self.deltaX));
},
});
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight") goTo(Math.round(state.value) + 1);
if (event.key === "ArrowLeft") goTo(Math.round(state.value) - 1);
});
window.addEventListener("resize", layout);
render();
</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>
원본 구현 프롬프트
# Cover Flow: an endless 3D row of photos fanned out like record sleeves
**Goal:** build a full-screen, infinitely looping cover-flow: the centre photo lies flat and faces the viewer, the others rotate away to either side like record sleeves in a crate. Drag, wheel, arrow keys or a click move through them.
## Visual behaviour
1. **One number drives the layout.** Keep a fractional, unbounded `position` (the index of the centre sleeve). For every sleeve `i` of `N` (12 here), compute its distance to the centre the short way round:
- `wrap(v) = ((v mod N) + N) mod N`, `d = wrap(i − position + N/2) − N/2` (range −N/2…N/2)
- `side = sign(d)`, `a = min(|d|, 1)` (0 at the centre, 1 from the first neighbour on)
2. **Transform per sleeve** (`w` = sleeve width in px):
- `x = d × w × 0.42 + side × a × w × 0.9` (the 0.9 is an extra gap so the centre sleeve stands alone)
- `rotateY = −side × a × 55°` (side sleeves turn towards the centre)
- `z = −a × 260px − |d| × 30px`
- `transform: translate3d(x, 0, z) rotateY(rotate)`
- `z-index = 100 − round(|d| × 10)`
- `opacity = clamp((N/2 − |d|) / 1, 0, 1)`: sleeves fade out over the last sleeve before the wrap point, so the jump from one end of the row to the other is never visible.
3. **Caption.** Under the row, a small caption shows the title of the sleeve nearest the centre (`round(position)`), updated as the position passes the halfway point.
4. **Moving.** Any discrete change (wheel, key, click, end of a drag) tweens `position` to a whole number over **0.8 s** with `power3.out` (≈ `cubic-bezier(0.165, 0.84, 0.44, 1)`, ease-out quart), overwriting any running tween. Re-layout on every tween update. There is no clamping: any whole number is a valid target, so the row loops endlessly.
## Input
| Input | Behaviour |
|---|---|
| Drag (horizontal) | Kill the running tween; `position −= deltaX × 0.004` per move (250px per sleeve), re-layout immediately. Drag threshold 3px |
| Release | Snap: `goTo(round(position − velocityX × 0.004 × 0.15))` (velocityX in px/s), a nudge in the direction of the flick |
| Wheel | `goTo(round(position) + sign(deltaY or deltaX))`, one sleeve per wheel event |
| Arrow keys | Right: `round(position) + 1`, Left: `round(position) − 1` |
| Click a side sleeve | If the pointer moved less than 4px: `goTo(round(position + d))`, the shortest way round |
Drag and wheel are read on the whole stage, not just the row.
## Exact values
| What | Value |
|---|---|
| Sleeve | 3:4, width `min(clamp(180px, 30vw, 360px), (100svh − 240px) × 0.75)`, radius 6px, `object-fit: cover`, light grey placeholder background |
| Perspective | 1400px on the row container |
| Spacing | 0.42 × sleeve width per sleeve |
| Centre gap | 0.9 × sleeve width extra for the neighbours |
| Angle | 55° |
| Depth | 260px + 30px per sleeve of distance |
| Drag speed | 0.004 sleeves per px |
| Flick | 0.15 × release velocity |
| Snap | 0.8 s, `power3.out` |
| Gap row → caption | `clamp(24px, 4svh, 40px)` |
| Caption | 12px, medium, uppercase, letter spacing 0.25em, grey |
## Markup and layout
```html
<div class="stage"> <!-- fixed full screen, flex column, centred -->
<div class="cf"> <!-- width 100%, height = sleeve height, perspective 1400px -->
<div class="cf-sleeve" data-title="…"><img src="…" alt=""></div>
…
</div>
<p class="cf-caption"></p>
</div>
```
- `.cf-sleeve`: `position: absolute; top: 0; left: 50%; margin-left: −w/2; transform-style: preserve-3d; overflow: hidden`.
- `.stage`: `touch-action: none` (horizontal drags never scroll), `user-select: none`, `cursor: grab`.
## Libraries
- **GSAP 3** with **Observer** (`type: "pointer,touch,wheel"`, `dragMinimum: 3`, using `onPress`, `onDragStart`, `onDrag`, `onDragEnd`, `onWheel`). `gsap.to(state, { value, duration: 0.8, ease: "power3.out", overwrite: true, onUpdate: render })`.
- **Plain alternative:** pointer events plus your own `requestAnimationFrame` tween with an ease-out-quart curve; a wheel listener that steps one sleeve per event (throttle trackpads if needed).
## Responsive and reduced motion
- The sleeve width scales with the viewport (formula above) and also shrinks to fit short screens. Re-layout on resize.
- `prefers-reduced-motion: reduce`: transitions are instant (duration 0); dragging still moves the row directly.
## Acceptance criteria
- The centre photo lies flat with clear space either side; neighbours are turned 55° away, stepped back in depth and overlapping like a stack.
- Arrow keys, wheel and clicks glide to the next sleeve in 0.8 s with a strong ease-out; the caption follows.
- Dragging moves the row continuously and snaps to the nearest sleeve on release, nudged by the flick.
- The row loops endlessly in both directions with no visible jump at the wrap point.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: c9b1a7f1a05822ac466dd42c199134a010b6407ff20434bf095aebf452564e1b