모션·인터랙션 / Cadence
Swipe Reveal
갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Swipe Reveal
An endless row of photographs, all the same height, swiped one photograph at a time: each
lands centred and holds. Every photograph lags the one before it by 40ms in the direction
of travel, so the gaps stretch a little and recover. A photograph grows from 92% to full
size as it nears the centre. Plays on its own; drag or swipe (touch, mouse, or a
trackpad's sideways scroll) for the next or previous photograph, click or tap for the
next one, arrow keys too.
Dependencies: none (plain JavaScript and CSS).
Tweak (constants at the top of the script):
- SLIDE: 0.7s per slide, eased with cubic-bezier(0.22, 0.61, 0.36, 1)
- STAGGER: 0.04s lag per photograph in the direction of travel (LAG_LIMIT: capped at 6)
- HOLD: 0.8s hold between slides (one advance = 1.5s)
- FRAME_H / GAP / FALLOFF: 560 / 18 / 520px in a 960 × 720 reference frame, scaled to
the row height; SCALE_MIN: 0.92 away from the centre
- ROW_VH / ROW_VW: the row is 62% of the viewport height, at most 96% of its width
- DRAG_FOLLOW: 0.5, the row follows a drag at half the pointer's distance
- SWIPE_MIN / FLICK / TAP_MAX: 40px drag, 0.45px/ms flick, < 6px counts as a click
- RESUME: autoplay carries on 2.4s after any input
From the "Design with Skills" explainer series, animation 00 (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Swipe Reveal</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #fff; }
.stage {
position: fixed;
inset: 0;
overflow: hidden;
cursor: grab;
touch-action: none; /* the stage takes the swipe */
user-select: none;
-webkit-user-select: none;
}
.stage.is-dragging {
cursor: grabbing;
}
.track {
position: absolute;
inset: 0;
}
/* One photograph. The script sets the height (and so the width), the position and the scale. */
.card {
position: absolute;
top: 0;
left: 0;
margin: 0;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 6px;
background: #efefef;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
will-change: transform;
}
.card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
-webkit-user-drag: none;
}
</style>
</head>
<body>
<!-- One row of photographs, all the same height. The script repeats them so the row is endless.
Swap the picsum.photos placeholders for your own 3:4 photographs. -->
<div class="stage" id="stage" role="region" aria-roledescription="carousel" aria-label="Photographs swipe in one at a time">
<div class="track" id="track">
<figure class="card"><img src="https://picsum.photos/seed/cadence-1/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-2/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-3/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-4/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-5/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-6/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-7/900/1200" alt="" draggable="false"></figure>
<figure class="card"><img src="https://picsum.photos/seed/cadence-8/900/1200" alt="" draggable="false"></figure>
</div>
</div>
<script>
(function () {
"use strict";
const SLIDE = 0.7; // s, the slide
const STAGGER = 0.04; // s, the lag per photograph, in the direction of travel
const HOLD = 0.8; // s, the hold between slides
const STEP = HOLD + SLIDE; // s, one advance
const FRAME_H = 560; // the row's height in the 960 x 720 reference frame…
const GAP = 18; // …the gap between photographs…
const FALLOFF = 520; // …and the distance from the centre over which a photograph grows
const SCALE_MIN = 0.92; // a photograph away from the centre
const COPIES = 3; // the set drawn three times over, so the row runs past both edges
const LAG_LIMIT = 6; // photographs past this are off screen anyway
const ROW_VH = 0.62; // row height as a share of the viewport height…
const ROW_VW = 0.96; // …but at most this share of its width (so a portrait fits a phone)
const DRAG_FOLLOW = 0.5; // the row follows a drag at half the pointer's distance
const SWIPE_MIN = 40; // px of drag (or a quick flick) that counts as a swipe
const FLICK = 0.45; // px/ms release speed that counts as a swipe on its own
const TAP_MAX = 6; // px of movement below which a press is a click
const WHEEL_MIN = 30; // px of sideways trackpad scroll that counts as a swipe
const WHEEL_COOLDOWN = 0.45; // s before the trackpad can trigger again
const RESUME = 2.4; // s after any input before autoplay carries on
// cubic-bezier(0.22, 0.61, 0.36, 1), solved with Newton's method
function cubicBezier(x1, y1, x2, y2) {
const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
const sx = (u) => ((ax * u + bx) * u + cx) * u;
const sy = (u) => ((ay * u + by) * u + cy) * u;
const dx = (u) => (3 * ax * u + 2 * bx) * u + cx;
return (x) => {
let u = x;
for (let i = 0; i < 8; i++) {
const e = sx(u) - x;
if (Math.abs(e) < 1e-5) break;
u -= e / Math.max(1e-6, dx(u));
}
return sy(Math.max(0, Math.min(1, u)));
};
}
const swipeEase = cubicBezier(0.22, 0.61, 0.36, 1);
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, p) => a + (b - a) * p;
/* Elements */
const stage = document.getElementById("stage");
const track = document.getElementById("track");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const slideDur = reduced ? 0 : SLIDE;
const lag = reduced ? 0 : STAGGER;
const originals = Array.from(track.children);
const N = originals.length;
for (let m = 1; m < COPIES; m++) {
originals.forEach((fig) => {
const c = fig.cloneNode(true);
c.setAttribute("aria-hidden", "true");
c.querySelector("img").alt = "";
track.appendChild(c);
});
}
// Each photograph keeps its own transition (from, to, start, delay),
// so a new swipe can start while one is still under way.
const cards = Array.from(track.children).map((el, j) => ({
el,
i: j % N,
m: Math.floor(j / N),
x0: 0,
from: 0,
to: 0,
t0: 0,
delay: 0,
}));
/* Layout */
let VW = 0, VH = 0, H = 0, W = 0, gap = 0, falloff = 0, CYCLE = 0, L = 0, A = 0;
let k = 0; // advances made (any integer; negative after swiping back)
const now = () => performance.now() / 1000;
const mod = (v, n) => ((v % n) + n) % n;
const centreShift = (n) => mod(n, N) * (W + gap) + W / 2 - VW / 2 + Math.floor(n / N) * CYCLE;
const wrapX = (x) => A + mod(x - A, L); // keeps every copy within one span centred on the viewport
function layout() {
VW = stage.clientWidth;
VH = stage.clientHeight;
H = Math.round(Math.min(VH * ROW_VH, VW * ROW_VW));
const u = H / FRAME_H;
W = Math.round((H * 3) / 4);
gap = GAP * u;
falloff = FALLOFF * u;
CYCLE = N * (W + gap);
L = COPIES * CYCLE;
A = VW / 2 - L / 2;
const s = centreShift(k);
cards.forEach((c) => {
c.x0 = c.i * (W + gap) + c.m * CYCLE;
c.from = c.to = s;
c.t0 = 0;
c.delay = 0;
c.el.style.height = H + "px";
c.el.style.width = W + "px";
});
}
/* Motion */
let dragX = 0; // the live drag offset, px
const shiftOf = (c, t) => {
if (slideDur === 0) return c.to;
const p = swipeEase(clamp((t - c.t0 - c.delay) / slideDur, 0, 1));
return lerp(c.from, c.to, p);
};
// Start a swipe of `dir` photographs (0 settles back after a short drag).
function go(dir) {
const t = now();
const travel = dir === 0 ? -Math.sign(dragX) : dir; // +1: the row moves left
const target = centreShift(k + dir);
cards.forEach((c) => {
const s = shiftOf(c, t) - dragX;
const x = wrapX(c.x0 - s);
// this photograph's place ahead of the centred one, in photographs
const slot = Math.round((x + W / 2 - VW / 2) / (W + gap));
c.delay = Math.min(LAG_LIMIT, Math.max(0, slot * travel)) * lag;
c.from = s;
c.to = target; // the same shift for every copy
c.t0 = t;
});
k += dir;
dragX = 0;
}
function render() {
const t = now();
cards.forEach((c) => {
const x = wrapX(c.x0 - shiftOf(c, t) + dragX);
const centred = Math.max(0, 1 - Math.abs(x + W / 2 - VW / 2) / falloff);
const scale = SCALE_MIN + (1 - SCALE_MIN) * centred;
c.el.style.transform =
"translate3d(" + x.toFixed(2) + "px," + ((VH - H) / 2).toFixed(2) + "px,0) scale(" + scale.toFixed(4) + ")";
});
}
/* Autoplay */
let nextAuto = now() + HOLD; // the first photograph holds, then the row moves
const pause = () => { nextAuto = now() + RESUME; };
function loop() {
const t = now();
if (!reduced && !dragging && t >= nextAuto) {
go(1);
nextAuto = t + STEP;
}
render();
requestAnimationFrame(loop);
}
/* Input */
let dragging = false, startX = 0, lastX = 0, lastT = 0, vel = 0, moved = 0, pid = null;
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
pid = e.pointerId;
startX = lastX = e.clientX;
lastT = e.timeStamp;
vel = 0;
moved = 0;
stage.setPointerCapture(pid);
stage.classList.add("is-dragging");
});
stage.addEventListener("pointermove", (e) => {
if (!dragging || e.pointerId !== pid) return;
const dt = Math.max(1, e.timeStamp - lastT);
vel = 0.8 * ((e.clientX - lastX) / dt) + 0.2 * vel;
lastX = e.clientX;
lastT = e.timeStamp;
const dx = e.clientX - startX;
moved = Math.max(moved, Math.abs(dx));
dragX = reduced ? 0 : dx * DRAG_FOLLOW;
});
function release(e) {
if (!dragging || e.pointerId !== pid) return;
dragging = false;
stage.classList.remove("is-dragging");
const dx = e.clientX - startX;
if (moved < TAP_MAX) go(1); // click / tap: the next photograph
else if (Math.abs(dx) > SWIPE_MIN || Math.abs(vel) > FLICK) go(dx < 0 ? 1 : -1);
else go(0); // too short: settle back
pause();
}
stage.addEventListener("pointerup", release);
stage.addEventListener("pointercancel", (e) => {
if (!dragging || e.pointerId !== pid) return;
dragging = false;
stage.classList.remove("is-dragging");
go(0);
pause();
});
let wheelAcc = 0, wheelReady = 0;
stage.addEventListener(
"wheel",
(e) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
e.preventDefault();
const t = now();
if (t < wheelReady) return;
wheelAcc += e.deltaX;
if (Math.abs(wheelAcc) > WHEEL_MIN) {
go(wheelAcc > 0 ? 1 : -1);
wheelAcc = 0;
wheelReady = t + WHEEL_COOLDOWN;
pause();
}
},
{ passive: false }
);
window.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") { go(1); pause(); }
else if (e.key === "ArrowLeft") { go(-1); pause(); }
});
window.addEventListener("resize", layout);
layout();
render();
requestAnimationFrame(loop);
})();
</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>
원본 구현 프롬프트
# Swipe Reveal: an endless photo row that swipes one image at a time with a rippling lag
**Goal:** build an endless horizontal row of same-height photographs that advances one photograph at a time, each landing centred and holding, where every photograph lags its neighbour slightly so the gaps stretch and recover during the move.
## Visual behaviour
1. **The row.** Portrait photographs (3:4) in one horizontal row, all the same height, vertically centred in a full-viewport stage on a white page. Row height = `min(62% of viewport height, 96% of viewport width)`, photo width = height × 3/4. All sizes come from a 960 × 720 reference frame scaled by `u = rowHeight / 560`: gap **18u**, scale falloff **520u**.
2. **Endless.** Repeat the set 3 times and wrap every photograph's x position into one span of `3 × setWidth` centred on the viewport, so photographs always run past both edges.
3. **Centred scale.** Each photograph scales from **0.92** (away from the centre) to **1** (centred), linearly with its distance from the viewport centre over the falloff distance: `scale = 0.92 + 0.08 × max(0, 1 − |distance| / falloff)`.
4. **One advance** (autoplay, every 1.5s):
- The row slides left by one photograph (photo width + gap) over **0.7s** with `cubic-bezier(0.22, 0.61, 0.36, 1)`.
- **Lag:** when the row moves left, each photograph to the right of the centred one (the incoming side) starts its slide later by **40ms** per slot: the next photograph waits 40ms, the one after 80ms, and so on, capped at **6 slots** (240ms). The centred photograph and everything to its left start immediately. Moving right (previous), it mirrors: photographs to the left wait. So the gaps on the incoming side stretch open as the row starts, then close as the late photographs catch up. Slot = `round((cardCentreX − viewportCentreX) / (photoWidth + gap))`.
- Then a **0.8s hold** with the new photograph centred. The first photograph holds 0.8s after load before the first move.
5. **Interruptible.** Each photograph keeps its own transition (from, to, start time, delay). A new swipe starts from wherever each photograph currently is, so swipes can overlap smoothly.
6. **Interaction** (autoplay pauses during a drag and resumes **2.4s** after any input):
- **Drag / swipe** (mouse, touch, pen via pointer events): the row follows at **half** the pointer's distance. On release: a drag over **40px** or a flick faster than **0.45 px/ms** goes to the next photograph (drag left) or the previous one (drag right); shorter drags settle back, with the same lagged slide.
- **Click / tap** (less than 6px of movement): next photograph.
- **Trackpad sideways scroll:** once 30px of horizontal delta accumulates, next or previous, then a 0.45s cooldown. Ignore mostly-vertical wheel events.
- **Arrow keys:** right = next, left = previous.
7. **Look.** 6px rounded corners, `object-fit: cover`, a soft shadow `0 24px 60px rgba(0,0,0,0.18)`, light grey placeholder. Cursor `grab` / `grabbing`.
## Exact values
| What | Value |
|---|---|
| Slide | 0.7s, `cubic-bezier(0.22, 0.61, 0.36, 1)` |
| Lag | 40ms per slot on the incoming side, max 6 slots (240ms) |
| Hold | 0.8s (one advance every 1.5s) |
| Row height | min(62vh, 96vw) |
| Gap / falloff | 18 / 520 at a 560px row height, scaled proportionally |
| Scale | 0.92 → 1 near the centre |
| Drag follow | 0.5 × pointer distance |
| Swipe threshold | 40px or 0.45 px/ms; tap < 6px |
| Trackpad | 30px sideways, 0.45s cooldown |
| Autoplay resume | 2.4s after input |
## Markup
```html
<div class="stage" role="region" aria-roledescription="carousel" aria-label="…">
<div class="track">
<figure class="card"><img src="…" alt="…" draggable="false"></figure>
…
</div>
</div>
```
- Stage: `position: fixed; inset: 0; overflow: hidden; touch-action: none; user-select: none`.
- Cards: `position: absolute; top: 0; left: 0`, positioned only with `transform: translate3d(x, y, 0) scale(s)`, set every frame from a `requestAnimationFrame` loop.
- Clone the set twice for the endless row and mark the clones `aria-hidden`.
- Recompute the layout on resize.
## Libraries
- None needed: plain JavaScript with a small cubic-bezier solver (Newton's method on the x curve) for the easing, and a rAF render loop.
- A tween library (GSAP etc.) could run the per-card tweens, but the per-card delay and interruptible from/to state is simplest by hand.
## Responsive and reduced motion
- The row height follows the viewport (62vh, but never wider than 96vw for one photograph), so a portrait always fits on a phone.
- `prefers-reduced-motion: reduce`: no autoplay and no glide or lag. Swipes, clicks and keys jump straight to the next or previous photograph, and the row doesn't follow the drag.
## Acceptance criteria
- On load the first photograph holds centred, then every 1.5s the row advances by exactly one photograph and lands perfectly centred.
- During each move the incoming photographs start slightly later one after another, so the gaps visibly stretch and then close.
- The centred photograph is full size and its neighbours are slightly smaller (92%), scaling smoothly as they pass the centre.
- The row is endless in both directions with no visible wrap or jump.
- Dragging moves the row at half speed and releases into a lagged slide. A click advances, arrow keys and trackpad swipes work, and autoplay resumes 2.4s after input.
- With reduced motion, nothing moves on its own and changes are instant.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 399deda69066f8def9ea64a2170eff8e38169cfa128c1820c3e854c92e4f244f