모션·인터랙션 / Cadence
Drifting Row
갤러리 · 내비게이션 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Drifting Row
An endless row of portrait cards that drifts slowly to the left. The scroll wheel
(vertical or horizontal) adds momentum that decays back into the drift; touch swipes
move the row and carry on with the last swipe speed. The set of cards is repeated five
times and the offset wraps by exactly one set width, so the loop never shows a seam.
Hovering a card fades the others.
Dependencies: none (requestAnimationFrame).
Tweak (in the script):
- BASE_VELOCITY: -0.6 px per frame (drift speed and direction; 0 = no drift)
- WHEEL_FACTOR: 0.6 (wheel delta → velocity)
- DECAY: 0.9 (velocity multiplier per frame; closer to 1 = longer glide)
- REPEAT: 5 copies of the set; CENTER_REPEAT_INDEX / CENTER_CARD: which card starts centred
Tweak (in the CSS): card width 22vw (min 220px), gap 3vw, hover fade to 0.3 over 0.5s.
From the Studio Burnett site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drifting Row</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
h3, p { margin: 0; }
.dr-stage {
position: fixed;
inset: 0;
touch-action: pan-y; /* horizontal swipes drive the row */
}
.dr-viewport {
position: absolute;
left: 0;
right: 0;
top: 50%;
width: 100%;
overflow: hidden;
transform: translateY(-50%);
}
.dr-row {
display: flex;
align-items: center;
gap: 3vw;
width: max-content;
padding-left: 3vw;
will-change: transform;
}
.dr-card {
flex: 0 0 auto;
width: 22vw;
min-width: 220px;
}
.dr-card img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 6px;
transition: opacity 0.5s ease;
backface-visibility: hidden;
user-select: none;
-webkit-user-drag: none;
}
.dr-meta { margin-top: 18px; }
.dr-meta h3 { margin-bottom: 4px; font-size: 14px; font-weight: 500; }
.dr-meta p { font-size: 13px; color: #8e8e8e; }
/* Hover: the other cards fade */
@media (hover: hover) {
.dr-row:has(.dr-card:hover) .dr-card:not(:hover) img { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="dr-stage">
<div class="dr-viewport">
<!-- One set of cards; the script repeats it. Swap the picsum.photos placeholders for your own portrait (3:4) images. -->
<div class="dr-row" id="row">
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-1/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>Linen</h3><p>Art Direction</p></div>
</div>
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-2/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>Camel Coat</h3><p>Photography</p></div>
</div>
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-3/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>Pier in Winter</h3><p>Editorial</p></div>
</div>
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-4/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>Lavender Rows</h3><p>Campaign</p></div>
</div>
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-5/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>Studio Light</h3><p>Brand Identity</p></div>
</div>
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-6/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>The Lighthouse</h3><p>Art Direction</p></div>
</div>
<div class="dr-card">
<img src="https://picsum.photos/seed/cadence-7/600/800" alt="" width="600" height="800">
<div class="dr-meta"><h3>Reading Room</h3><p>Photography</p></div>
</div>
</div>
</div>
</div>
<script>
/* Constants ------------------------------------------------------------- */
const REPEAT = 5; // the set of cards is repeated for the infinite row
const CENTER_REPEAT_INDEX = 2; // the visible set at the start
const CENTER_CARD = 3; // card of that set centred at the start (the 4th)
const BASE_VELOCITY = -0.6; // px per frame (leftward drift)
const WHEEL_FACTOR = 0.6; // wheel delta → velocity
const DECAY = 0.9; // velocity multiplier per frame
const MIN_VELOCITY = 0.01; // below this the added velocity is dropped
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const baseVelocity = reduceMotion ? 0 : BASE_VELOCITY; // reduced motion: no automatic drift
/* Build the repeated row -------------------------------------------------- */
const row = document.getElementById("row");
const originals = Array.from(row.children);
const COUNT = originals.length;
row.replaceChildren();
for (let r = 0; r < REPEAT; r++) {
originals.forEach((card) => {
const clone = card.cloneNode(true);
if (r !== CENTER_REPEAT_INDEX) clone.setAttribute("aria-hidden", "true");
row.append(clone);
});
}
const cards = Array.from(row.children);
/* Measurements ------------------------------------------------------------ */
let baseOffset = 0; // centres the start card
let setWidth = 0; // width of one set (card + gap) × COUNT
// offsetLeft ignores the row's transform, so this can run at any time.
function measure() {
const target = cards[CENTER_REPEAT_INDEX * COUNT + CENTER_CARD];
baseOffset = window.innerWidth / 2 - (target.offsetLeft + target.offsetWidth / 2);
setWidth = cards[COUNT].offsetLeft - cards[0].offsetLeft;
}
/* Drift + wheel ----------------------------------------------------------- */
let scrollX = 0;
let userVel = 0;
function onWheel(e) {
e.preventDefault();
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
userVel -= d * WHEEL_FACTOR;
}
function tick() {
scrollX += baseVelocity + userVel;
userVel *= DECAY;
if (Math.abs(userVel) < MIN_VELOCITY) userVel = 0;
// Wrap by exactly one set width so the loop is seamless.
if (setWidth > 0) {
while (scrollX <= -setWidth) scrollX += setWidth;
while (scrollX > 0) scrollX -= setWidth;
}
row.style.transform = `translateX(${baseOffset + scrollX}px)`;
requestAnimationFrame(tick);
}
/* Touch: the finger moves the row; the last move carries on as velocity. */
let touchX = null;
let touchVel = 0;
window.addEventListener("touchstart", (e) => {
touchX = e.touches[0].clientX;
touchVel = 0;
userVel = 0;
}, { passive: true });
window.addEventListener("touchmove", (e) => {
if (touchX === null) return;
const x = e.touches[0].clientX;
touchVel = x - touchX;
scrollX += touchVel;
touchX = x;
}, { passive: true });
window.addEventListener("touchend", () => {
touchX = null;
userVel = touchVel;
});
/* Start ------------------------------------------------------------------- */
measure();
row.style.transform = `translateX(${baseOffset}px)`;
window.addEventListener("wheel", onWheel, { passive: false });
window.addEventListener("resize", measure);
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>
원본 구현 프롬프트
# Drifting Row: endless row of cards that drifts left, with wheel momentum
**Goal:** build an infinite horizontal row of portrait cards that drifts slowly to the left on its own; the scroll wheel (or a swipe) throws it faster in either direction, and the extra speed decays smoothly back into the drift.
## Visual behaviour
1. One row of cards, vertically centred in the viewport on a white page. Each card is a 3:4 photo with rounded corners (6px), and below it a title (14px, medium weight) and a tag (13px, grey `#8e8e8e`). The page itself doesn't scroll.
2. **Infinite row.** The set of cards (7 here) is cloned 5 times in a row. At the start, the 4th card of the 3rd (middle) set is centred horizontally in the viewport.
3. **Drift.** Every animation frame the row moves **0.6px to the left**. That is about 36px per second at 60fps; it's a per-frame value, as in the original.
4. **Wheel momentum.** A wheel event adds velocity: `velocity -= delta × 0.6`, where `delta` is `deltaX` if it is larger than `deltaY`, otherwise `deltaY` (so both vertical and horizontal scrolling work, and scrolling down/right pushes the row left). Call `preventDefault()` on the wheel event.
5. **Decay.** Every frame: `offset += drift + velocity`, then `velocity *= 0.9`. When `|velocity| < 0.01`, set it to 0. A single wheel delta `d` therefore moves the row about `6 × d` pixels in total, front-loaded, before the motion settles back into the drift.
6. **Wrap.** Let `setWidth` be the distance from the first card of set 1 to the first card of set 2 (one set's width including gaps). Keep the offset in the range `(-setWidth, 0]` by adding or subtracting `setWidth`. Because every set is identical, the jump is invisible and the row never ends in either direction.
7. Apply the transform as `translateX(baseOffset + offset)`, where `baseOffset` centres the start card: `viewportWidth / 2 − (card.offsetLeft + card.offsetWidth / 2)`.
8. **Touch.** On `touchstart`, remember the finger x and reset the velocity. On `touchmove`, move the row by the finger's movement since the last event. On `touchend`, the last movement becomes the velocity, so the row glides on and decays as above.
9. **Hover.** When a card is hovered, the photos of all other cards fade to opacity 0.3 (0.5s ease). Only on devices with hover.
## Exact values
| What | Value |
|---|---|
| Drift | −0.6 px per frame (leftward) |
| Wheel → velocity | `velocity -= delta × 0.6` |
| Decay | velocity × 0.9 per frame, dropped below 0.01 |
| Copies of the set | 5; start centred on card 4 of copy 3 |
| Card width | 22vw, min 220px; photo ratio 3:4, `object-fit: cover` |
| Gap | 3vw between cards, 3vw left padding on the row |
| Caption | 18px below the photo; title 14px / 500 with 4px below; tag 13px grey |
| Hover fade | other photos to opacity 0.3, `transition: opacity 0.5s ease` |
## Markup and structure
```html
<div class="stage"> <!-- fixed, full viewport, touch-action: pan-y -->
<div class="viewport"> <!-- absolute, full width, top 50%, translateY(-50%), overflow hidden -->
<div class="row"> <!-- display: flex; gap: 3vw; width: max-content; will-change: transform -->
<div class="card">
<img src="…" alt="…">
<div class="meta"><h3>Title</h3><p>Tag</p></div>
</div>
…
</div>
</div>
</div>
```
- Clone the set in JS (5 copies). Mark every copy except the middle one `aria-hidden="true"`.
- Measure with `offsetLeft` (which ignores the transform), so measuring works at any time. Measure again on resize.
- Hover CSS: `.row:has(.card:hover) .card:not(:hover) img { opacity: 0.3 }` inside `@media (hover: hover)`.
## Libraries
- None. One `requestAnimationFrame` loop holds the drift, momentum, decay and wrap.
- If you want the speed to be frame-rate independent, scale drift and decay by the frame time (`drift × dt/16.67`, `velocity × 0.9^(dt/16.67)`); the original uses fixed per-frame values.
## Responsive and reduced motion
- The cards are 22vw wide with a 220px minimum, so on phones about 1.5 cards are visible. Horizontal swipes move the row; `touch-action: pan-y` on the stage keeps vertical page gestures working.
- `prefers-reduced-motion: reduce`: no automatic drift (drift = 0). The wheel and swipes still move the row.
## Acceptance criteria
- On load the row is centred on the 4th card of the middle set and drifts left at a slow, constant speed.
- A wheel flick (down or right) throws the row left; up or left throws it right. In both cases it slows smoothly and settles back into the leftward drift.
- The row never runs out of cards and the wrap point is invisible, however far it travels in either direction.
- A swipe drags the row with the finger and lets it glide on after release.
- Hovering a card fades the other photos to 30%.
- With reduced motion the row stands still until the user scrolls or swipes.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: df0c67cae3cbef69efbaf4239bce6e19fc2550386c88029547059aa30a7dcd75