모션·인터랙션 / Cadence
Depth Carousel
갤러리 · 3D 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Depth Carousel
Nine landscape photos sit on the face of a vertical drum (a ring that turns around the
horizontal axis), seen slightly from the side. Drag up or down, scroll, or use the arrow
keys to roll it; it glides to a stop with one card square to the viewer. Cards dissolve
into white as they turn away, and the drum sinks back and leans while it moves. Clicking
a card brings it to the front (clicking the front card moves on by one). On load the drum
rolls in from 2.4 cards back; left alone for 5 s, it advances by itself every 3.6 s.
Dependencies: none (CSS 3D transforms and plain JavaScript).
Tweak (constants at the top of the script):
- CARD_GAP 0.06 (gap between cards, × card height), PERSPECTIVE_RATIO 2.6 (× drum radius)
- GLIDE_EASE 0.085 (share of the remaining distance per 60 fps frame), INTRO_FROM -2.4
- WHEEL_PX_PER_CARD 360, WHEEL_SNAP_DELAY 160 ms, FLING_TIME 260 ms, FLING_MAX 3 cards
- YAW -10°, LEAN_PER_CARD 7° (max 9°), PUSH_PER_CARD 70px (max 150px), FOG_GAIN 0.95
- AUTOPLAY_IDLE 5000 ms, AUTOPLAY_EVERY 3600 ms
- Card size: --card-w in the CSS (3:2 cards)
Inspired by "On-Scroll 3D Carousel" by Codrops
https://github.com/codrops/3DCarousel · https://tympanus.net/Development/3DCarousel/
MIT License · Copyright (c) 2009 - 2024 Codrops
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Depth Carousel</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; background: #fff; }
.stage {
--card-w: min(36vw, 560px, 64vh); /* 3:2 cards; the last term keeps short, wide screens in check */
--card-h: calc(var(--card-w) / 1.5);
position: fixed;
inset: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
/* perspective is set by the script (a multiple of the drum radius) */
}
.stage.is-dragging,
.stage.is-dragging .card { cursor: grabbing; }
.drum {
position: absolute;
top: calc(50% - var(--card-h) / 2);
left: calc(50% - var(--card-w) / 2);
width: var(--card-w);
height: var(--card-h);
transform-style: preserve-3d;
will-change: transform;
}
.card {
position: absolute;
inset: 0;
margin: 0;
overflow: hidden;
border-radius: 6px;
background: #f0f0f0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
cursor: pointer;
}
.card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
-webkit-user-drag: none;
}
/* White mist (the page colour): cards dissolve into the page as they turn away */
.fog {
position: absolute;
inset: 0;
background: #fff;
opacity: 1;
pointer-events: none;
}
@media (max-width: 700px) {
.stage { --card-w: min(84vw, 520px); }
}
</style>
</head>
<body>
<!-- Swap the picsum.photos placeholders for your own 3:2 landscape photos (any number of cards works). -->
<div class="stage" data-stage>
<div class="drum" data-drum>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-1/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-2/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-3/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-4/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-5/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-6/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-7/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-8/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
<figure class="card" data-card><img src="https://picsum.photos/seed/cadence-9/1600/1067" alt="" draggable="false"><span class="fog"></span></figure>
</div>
</div>
<script>
const CARD_GAP = 0.06; // gap between neighbouring cards, as a share of the card height
const PERSPECTIVE_RATIO = 2.6; // perspective = drum radius × this
const GLIDE_EASE = 0.085; // share of the remaining distance covered per 60fps frame
const INTRO_FROM = -2.4; // the drum rolls in from 2.4 cards back
const WHEEL_PX_PER_CARD = 360; // wheel/trackpad delta that turns the drum by one card
const WHEEL_MAX_AHEAD = 2.5; // the wheel can run at most this many cards ahead
const WHEEL_SNAP_DELAY = 160; // ms after the last wheel event before snapping to a card
const DRAG_CLICK_TOLERANCE = 6; // px of movement that still counts as a click
const FLING_TIME = 260; // ms of release velocity that is carried on after a drag
const FLING_MAX = 3; // cards
const YAW = -10; // degrees: the drum is seen slightly from the side, so its depth reads
const LEAN_PER_CARD = 7; // degrees of rotateY lean per card of travel still to go
const LEAN_MAX = 9; // degrees
const LEAN_EASE = 0.08;
const PUSH_PER_CARD = 70; // px the drum sinks back per card of travel still to go
const PUSH_MAX = 150; // px
const FOG_GAIN = 0.95; // mist opacity = (1 − cos(angle from the viewer)) × FOG_GAIN
const AUTOPLAY_IDLE = 5000; // ms without input before autoplay starts
const AUTOPLAY_EVERY = 3600; // ms between autoplay steps
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stage = document.querySelector("[data-stage]");
const drum = document.querySelector("[data-drum]");
const cards = Array.from(drum.querySelectorAll("[data-card]"));
const fogs = cards.map((card) => card.querySelector(".fog"));
const COUNT = cards.length;
const STEP = (Math.PI * 2) / COUNT; // radians between cards
const STEP_DEG = 360 / COUNT;
let radius = 0;
let stepPx = 1; // arc length of one card at the front, for 1:1 dragging
let current = reducedMotion ? 0 : INTRO_FROM; // drum position, in cards
let target = 0;
let lean = 0;
let push = 0;
let lastInput = performance.now();
let lastAuto = 0;
let wheelTimer = 0;
let drag = null;
let running = false;
const mod = (n, m) => ((n % m) + m) % m;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
/* Layout: the radius follows the card height, so neighbours nearly touch */
function layout() {
const h = drum.offsetHeight;
radius = (h * (1 + CARD_GAP)) / 2 / Math.tan(STEP / 2);
stepPx = radius * STEP;
stage.style.perspective = `${Math.round(radius * PERSPECTIVE_RATIO)}px`;
cards.forEach((card, i) => {
// card i sits i steps below the front card
card.style.transform = `rotateX(${-i * STEP_DEG}deg) translateZ(${radius}px)`;
});
}
/* Render loop */
let last = performance.now();
function frame(now) {
const dt = Math.min(64, now - last) / (1000 / 60);
last = now;
// Autoplay after a pause
if (running && !reducedMotion && !drag && now - lastInput > AUTOPLAY_IDLE && now - lastAuto > AUTOPLAY_EVERY) {
target = Math.round(target) + 1;
lastAuto = now;
}
// Exponential glide towards the target (frame-rate independent)
const k = reducedMotion ? 1 : 1 - Math.pow(1 - GLIDE_EASE, dt);
current += (target - current) * k;
if (Math.abs(target - current) < 0.0004) current = target;
// Lean and push-back follow the distance still to travel
const ahead = target - current;
const leanTo = reducedMotion ? 0 : clamp(ahead * LEAN_PER_CARD, -LEAN_MAX, LEAN_MAX);
const pushTo = reducedMotion ? 0 : Math.min(Math.abs(ahead) * PUSH_PER_CARD, PUSH_MAX);
const kl = reducedMotion ? 1 : 1 - Math.pow(1 - LEAN_EASE, dt);
lean += (leanTo - lean) * kl;
push += (pushTo - push) * kl;
drum.style.transform =
// yaw pivots on the front face (before translateZ), so the front card stays in place
`translate3d(0, 0, ${(-push).toFixed(2)}px) rotateY(${(YAW + lean).toFixed(3)}deg) ` +
`translateZ(${(-radius).toFixed(2)}px) rotateX(${(current * STEP_DEG).toFixed(3)}deg)`;
// Mist per card from its angle to the viewer; cards facing away are hidden
for (let i = 0; i < COUNT; i++) {
const rel = mod((i - current) * STEP + Math.PI, Math.PI * 2) - Math.PI;
const c = Math.cos(rel);
fogs[i].style.opacity = clamp((1 - c) * FOG_GAIN, 0, 1).toFixed(3);
cards[i].style.visibility = c < -0.05 ? "hidden" : "visible";
}
requestAnimationFrame(frame);
}
/* Input: wheel, drag (with fling), click, arrow keys */
function touch() {
lastInput = performance.now();
lastAuto = lastInput;
}
function goTo(index) {
const base = Math.round(target);
let d = mod(index - mod(base, COUNT), COUNT);
if (d > COUNT / 2) d -= COUNT; // shortest way round
target = base + d;
}
function step(dir) {
target = Math.round(target) + dir;
}
window.addEventListener(
"wheel",
(e) => {
if (!running) return;
e.preventDefault();
touch();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1;
const delta = (Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX) * unit;
target = clamp(target + delta / WHEEL_PX_PER_CARD, current - WHEEL_MAX_AHEAD, current + WHEEL_MAX_AHEAD);
clearTimeout(wheelTimer);
wheelTimer = setTimeout(() => (target = Math.round(target)), WHEEL_SNAP_DELAY);
},
{ passive: false }
);
stage.addEventListener("pointerdown", (e) => {
if (!running || e.button !== 0) return;
touch();
drag = {
id: e.pointerId,
startY: e.clientY,
startTarget: target,
moved: 0,
card: e.target.closest("[data-card]"),
samples: [{ t: e.timeStamp, y: e.clientY }],
};
stage.setPointerCapture(e.pointerId);
});
stage.addEventListener("pointermove", (e) => {
if (!drag || e.pointerId !== drag.id) return;
touch();
const dy = e.clientY - drag.startY;
drag.moved = Math.max(drag.moved, Math.abs(dy));
if (drag.moved > DRAG_CLICK_TOLERANCE) stage.classList.add("is-dragging");
target = drag.startTarget - dy / stepPx; // drag up → the next card rises into view
drag.samples.push({ t: e.timeStamp, y: e.clientY });
while (drag.samples.length > 2 && e.timeStamp - drag.samples[0].t > 100) drag.samples.shift();
});
function endDrag(e) {
if (!drag || e.pointerId !== drag.id) return;
touch();
stage.classList.remove("is-dragging");
if (drag.moved <= DRAG_CLICK_TOLERANCE) {
if (drag.card) {
const i = cards.indexOf(drag.card);
if (i === mod(Math.round(target), COUNT)) step(1);
else goTo(i);
}
} else {
const first = drag.samples[0];
const lastSample = drag.samples[drag.samples.length - 1];
const dt = Math.max(1, lastSample.t - first.t);
const v = (lastSample.y - first.y) / dt; // px per ms
const fling = clamp((-v * FLING_TIME) / stepPx, -FLING_MAX, FLING_MAX);
target = Math.round(target + (e.type === "pointercancel" ? 0 : fling));
}
drag = null;
}
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
window.addEventListener("keydown", (e) => {
if (!running) return;
const next = ["ArrowDown", "ArrowRight", "PageDown", " "];
const prev = ["ArrowUp", "ArrowLeft", "PageUp"];
if (next.includes(e.key)) step(1);
else if (prev.includes(e.key)) step(-1);
else return;
e.preventDefault();
touch();
});
window.addEventListener("resize", layout);
/* Start: hold the drum at its intro position until the photos are ready */
function preload() {
const waits = cards.map((card) => {
const img = card.querySelector("img");
return (img.complete ? Promise.resolve() : new Promise((res) => {
img.addEventListener("load", res, { once: true });
img.addEventListener("error", res, { once: true });
})).then(() => img.decode?.().catch(() => {}));
});
const timeout = new Promise((res) => setTimeout(res, 8000));
return Promise.race([Promise.all(waits), timeout]);
}
layout();
requestAnimationFrame((t) => {
last = t;
const hold = target;
target = current;
frame(t);
preload().then(() => {
target = hold;
running = true;
touch();
});
});
</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>
원본 구현 프롬프트
# Depth Carousel: a vertical 3D drum of photos that rolls card by card
**Goal:** build a full-screen carousel where landscape photos sit on the face of a vertical drum (a ring turning around the horizontal axis). Drag, scroll or arrow keys roll it; it glides to a stop with one card square to the viewer, and cards dissolve into white as they turn away.
## Visual behaviour
1. **The drum.** Nine 3:2 photos (any count works) sit evenly around a ring that rotates around the X axis. Card `i` is placed with `rotateX(-i × 360/N deg) translateZ(radius)`, so card 0 faces the viewer and the next card sits just below it, tilted away.
2. **Radius from the card height:** `radius = cardHeight × (1 + 0.06) / 2 / tan(π / N)`, so neighbours almost touch with a gap of 6% of the card height. The container's `perspective` is `radius × 2.6` (rounded to px).
3. **Seen slightly from the side:** the whole drum is turned `rotateY(-10deg)` around its front face, so its depth reads. The front card stays in place.
4. **Drum transform** (applied every frame, in this order):
`translate3d(0, 0, -push px) rotateY((-10 + lean) deg) translateZ(-radius px) rotateX(current × 360/N deg)`
where `current` is the drum position in cards (a float).
5. **Glide.** Input only changes a `target` position (in cards). Every frame, `current` moves towards `target` by 8.5% of the remaining distance per 60 fps frame, frame-rate independent: `k = 1 − (1 − 0.085)^(dt / 16.67ms)`; snap when closer than 0.0004 cards.
6. **Lean and push-back while moving.** With `ahead = target − current`:
- lean target = `clamp(ahead × 7°, −9°, 9°)` (added to the rotateY)
- push target = `min(|ahead| × 70px, 150px)` (the drum sinks back in Z)
- both follow their targets with the same kind of smoothing at 0.08 per frame.
7. **White mist.** Each card has a white overlay (the page colour). For the card's angle from the viewer `rel` (wrapped to −π…π): `opacity = clamp((1 − cos(rel)) × 0.95, 0, 1)`. The front card is clear; cards fade to white as they turn away. Cards with `cos(rel) < −0.05` are hidden (`visibility: hidden`), and all cards use `backface-visibility: hidden`.
8. **Intro.** On load the drum starts at −2.4 cards and glides to 0 once the photos have loaded (hold it at −2.4 until then, with an 8 s timeout).
9. **Autoplay.** After 5 s without input, it steps forward one card every 3.6 s. Any input resets the idle timer.
## Input
| Input | Behaviour |
|---|---|
| Drag (vertical) | `target = startTarget − dy / stepPx`, where `stepPx = radius × 2π/N` (1:1 with the front card). Drag up → the next card rises into view |
| Release | Fling: velocity over the last ~100 ms of samples (px/ms) × 260 ms, converted to cards, clamped to ±3 cards; then `target = round(target + fling)` |
| Click (≤ 6px of movement) | Clicked card comes to the front the shortest way round; clicking the front card moves on by one |
| Wheel / trackpad | `target += delta / 360` (360px per card), using the dominant axis, normalising `deltaMode` (lines × 16, pages × viewport height); target may run at most 2.5 cards ahead of `current`. 160 ms after the last wheel event, round `target` to a whole card. `preventDefault` (non-passive listener) |
| Arrow keys | Down / Right / PageDown / Space = next, Up / Left / PageUp = previous: `target = round(target) ± 1` |
## Exact values
| What | Value |
|---|---|
| Card | 3:2, width `min(36vw, 560px, 64vh)` (`min(84vw, 520px)` below 700px), radius 6px, `object-fit: cover` |
| Card gap | 0.06 × card height |
| Perspective | radius × 2.6 |
| Yaw | −10° |
| Glide | 0.085 per 60 fps frame (exponential) |
| Lean | 7° per card ahead, max 9°, smoothing 0.08 |
| Push-back | 70px per card ahead, max 150px, smoothing 0.08 |
| Mist | `(1 − cos θ) × 0.95` |
| Intro | from −2.4 cards |
| Wheel | 360px per card, max 2.5 cards ahead, snap after 160 ms |
| Fling | 260 ms of release velocity, max 3 cards |
| Autoplay | after 5000 ms idle, every 3600 ms |
## Markup and layout
```html
<div class="stage"> <!-- fixed, inset 0, overflow hidden, touch-action none, cursor grab -->
<div class="drum"> <!-- centred, card-sized, transform-style: preserve-3d -->
<figure class="card"><img src="…" alt=""><span class="fog"></span></figure>
…
</div>
</div>
```
- `.drum`: `position: absolute; top: calc(50% − cardH/2); left: calc(50% − cardW/2)`, card size, `transform-style: preserve-3d`.
- `.card`: `position: absolute; inset: 0; overflow: hidden; backface-visibility: hidden`.
- `.fog`: `position: absolute; inset: 0; background: #fff; pointer-events: none`.
- Images: `pointer-events: none`, `draggable="false"`.
## Libraries
- None. Plain CSS 3D transforms and a `requestAnimationFrame` loop. Use pointer events with `setPointerCapture`.
## Responsive and reduced motion
- Below 700px the cards grow to `min(84vw, 520px)` wide. Recompute the radius and perspective on resize.
- `prefers-reduced-motion: reduce`: no intro, no autoplay, no lean or push; the drum jumps straight to its target (k = 1). Input still works.
## Acceptance criteria
- On load the drum rolls up from 2.4 cards back and settles with one card square to the viewer, slightly turned to the side.
- Dragging moves the drum 1:1 with the pointer; releasing flings and snaps to a whole card.
- Scrolling rolls it smoothly and snaps to a card shortly after the scroll stops; arrow keys step one card.
- While moving, the drum leans into the motion and sinks back, then settles flat again.
- Cards fade to white as they turn away; no back faces are visible.
- Clicking a side card brings it to the front; clicking the front card advances by one.
- After 5 s idle, it advances on its own every 3.6 s.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 50d96617ed28edd99a2a452b09efbfaea0bc1b73004b741e799f76684983a175