모션·인터랙션 / Cadence
Draggable Grid
갤러리 · 내비게이션 · 로딩 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Draggable Grid
An endless, column-staggered grid of square photo tiles that fills the screen. Drag in
any direction; on release the grid keeps gliding with frame-rate-independent friction.
Only the visible tiles exist: they're recycled from a pool as they scroll off. On load
the tiles fade in and settle as a slow diagonal wave from top-left to bottom-right.
Dependencies: none (plain HTML, CSS and JS).
Tweak:
- TILE_WIDTH: 200px (170 under 1024px, 140 under 640px) · TILE_ASPECT: 1 (square)
- GAP_X / GAP_Y: 110px · STAGGER: 0.5 (odd columns shift down by half a row)
- FRICTION: 0.94 velocity decay per 60fps frame · VELOCITY_STOP: 0.05 px/ms
- VELOCITY_SAMPLE_MS: 80 ms release-velocity window · VELOCITY_CAP: 4 px/ms
- Entrance: ENTRANCE_STEP_MS 140 per diagonal step + ENTRANCE_SUBSTEP_MS 55 per column;
each tile fades in over 4000ms and drops 22px over 4200ms, cubic-bezier(0.2, 0.7, 0.2, 1)
- IMAGES / K: image = (column + K × row) mod N, so no neighbour repeats a photo
Based on the hero grid of the Paul Hamilton portfolio (Paul Hamilton, own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Draggable Grid</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
overflow: hidden;
background: #fff;
-webkit-user-select: none;
user-select: none;
}
.dg-hero {
position: fixed;
inset: 0;
overflow: hidden;
cursor: grab;
touch-action: none;
-webkit-touch-callout: none;
}
.dg-hero.dragging { cursor: grabbing; }
.dg-grid {
position: absolute;
inset: 0;
will-change: transform;
}
.dg-tile {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
border-radius: 6px;
background: #fff;
will-change: transform;
contain: layout paint size style;
}
.dg-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
pointer-events: none;
}
/* Entrance: tiles created on first render fade in and settle, delayed per tile
(--enter-delay) so the wave sweeps from top-left to bottom-right. */
.dg-tile.entering { background: transparent; }
.dg-tile.entering img {
opacity: 0;
transform: translateY(-22px);
transition:
opacity 4000ms cubic-bezier(0.2, 0.7, 0.2, 1) var(--enter-delay, 0ms),
transform 4200ms cubic-bezier(0.2, 0.7, 0.2, 1) var(--enter-delay, 0ms);
}
.dg-tile.entering.entered img {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.dg-tile.entering img {
transition-duration: 1ms;
transition-delay: 0ms;
}
}
</style>
</head>
<body>
<main class="dg-hero" id="hero" aria-label="Draggable photo grid. Drag in any direction.">
<div class="dg-grid" id="grid"></div>
</main>
<script>
(() => {
const TILE_WIDTH = 200; // px on desktop (170 under 1024px, 140 under 640px)
const TILE_ASPECT = 1; // square tiles
const GAP_X = 110;
const GAP_Y = 110;
const STAGGER = 0.5; // odd columns shift down by half a row
const FRICTION = 0.94; // velocity decay per 60fps frame
const VELOCITY_STOP = 0.05; // px/ms: stop the glide below this
const VELOCITY_SAMPLE_MS = 80; // window used to estimate release velocity
const VELOCITY_CAP = 4.0; // px/ms: maximum flick speed
const ENTRANCE_STEP_MS = 140; // delay per diagonal step
const ENTRANCE_SUBSTEP_MS = 55; // extra offset per column, so no two tiles start together
const ENTRANCE_TRANSITION_MS = 4200;
const ENTRANCE_IMG_TIMEOUT_MS = 1500; // don't wait forever for images
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Swap these picsum.photos placeholders for your own photos (they're centre-cropped
// into square tiles). With N images, pick K coprime to N with K - 1 and K + 1 not
// multiples of N (K = 5 for 12 images, K = 7 for 25), so no neighbour repeats a photo.
const IMAGES = Array.from({ length: 12 }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/400/400`);
const N = IMAGES.length;
const K = 5;
const imageFor = (c, r) => IMAGES[(((c + K * r) % N) + N) % N];
/* Layout ----------------------------------------------------------------- */
const hero = document.getElementById("hero");
const grid = document.getElementById("grid");
let tileW, tileH, colStep, rowStep;
function computeLayout() {
const vw = window.innerWidth;
tileW = vw < 640 ? 140 : vw < 1024 ? 170 : TILE_WIDTH;
tileH = Math.round(tileW / TILE_ASPECT);
colStep = tileW + GAP_X;
rowStep = tileH + GAP_Y;
}
computeLayout();
let ox = 0; // pan offset
let oy = 0;
let entranceArmed = !reducedMotion;
const tilePool = [];
const liveTiles = new Map(); // "c,r" → { el, img, src, c, r }
function acquireTile() {
if (tilePool.length) return tilePool.pop();
const el = document.createElement("div");
el.className = "dg-tile";
if (entranceArmed) el.classList.add("entering");
const img = document.createElement("img");
img.alt = "";
img.draggable = false;
img.decoding = "async";
el.append(img);
grid.append(el);
return { el, img, src: "" };
}
function releaseTile(t) {
t.el.style.transform = "translate3d(-99999px, -99999px, 0)";
tilePool.push(t);
}
function render() {
const W = window.innerWidth;
const H = window.innerHeight;
const cMin = Math.floor((-ox - tileW) / colStep) - 1;
const cMax = Math.floor((-ox + W) / colStep) + 1;
const needed = new Set();
for (let c = cMin; c <= cMax; c++) {
const colX = c * colStep + ox;
const stagger = (((c % 2) + 2) % 2) * rowStep * STAGGER;
const rMin = Math.floor((-oy - stagger - tileH) / rowStep) - 1;
const rMax = Math.floor((-oy - stagger + H) / rowStep) + 1;
for (let r = rMin; r <= rMax; r++) {
const key = `${c},${r}`;
needed.add(key);
let t = liveTiles.get(key);
if (!t) {
t = acquireTile();
t.c = c;
t.r = r;
t.el.style.width = `${tileW}px`;
t.el.style.height = `${tileH}px`;
const src = imageFor(c, r);
if (t.src !== src) {
t.img.src = src;
t.src = src;
}
liveTiles.set(key, t);
}
t.el.style.transform = `translate3d(${colX}px, ${r * rowStep + oy + stagger}px, 0)`;
}
}
for (const [key, t] of liveTiles) {
if (!needed.has(key)) {
liveTiles.delete(key);
releaseTile(t);
}
}
}
/* Drag with inertia -------------------------------------------------------- */
let dragging = false;
let pointerId = null;
let lastX = 0;
let lastY = 0;
let samples = [];
let vx = 0; // px/ms
let vy = 0;
let rafId = null;
let lastFrameTime = 0;
function startInertiaLoop() {
if (rafId != null) return;
lastFrameTime = performance.now();
const step = (now) => {
const dt = Math.min(48, now - lastFrameTime);
lastFrameTime = now;
if (!dragging) {
ox += vx * dt;
oy += vy * dt;
const decay = Math.pow(FRICTION, dt / (1000 / 60)); // frame-rate independent
vx *= decay;
vy *= decay;
if (Math.hypot(vx, vy) < VELOCITY_STOP) {
vx = vy = 0;
render();
rafId = null;
return;
}
}
render();
rafId = requestAnimationFrame(step);
};
rafId = requestAnimationFrame(step);
}
function pushSample(x, y) {
const now = performance.now();
samples.push({ t: now, x, y });
while (samples.length > 1 && now - samples[0].t > VELOCITY_SAMPLE_MS) samples.shift();
}
function estimateVelocity() {
if (samples.length < 2) return { vx: 0, vy: 0 };
const first = samples[0];
const last = samples[samples.length - 1];
const dt = last.t - first.t;
if (dt <= 0) return { vx: 0, vy: 0 };
let evx = (last.x - first.x) / dt;
let evy = (last.y - first.y) / dt;
const speed = Math.hypot(evx, evy);
if (speed > VELOCITY_CAP) {
evx *= VELOCITY_CAP / speed;
evy *= VELOCITY_CAP / speed;
}
return { vx: evx, vy: evy };
}
hero.addEventListener("pointerdown", (e) => {
e.preventDefault(); // no text/image selection while dragging
window.getSelection()?.removeAllRanges();
dragging = true;
pointerId = e.pointerId;
hero.setPointerCapture(pointerId);
hero.classList.add("dragging");
lastX = e.clientX;
lastY = e.clientY;
samples = [];
pushSample(e.clientX, e.clientY);
vx = vy = 0;
startInertiaLoop();
});
hero.addEventListener("pointermove", (e) => {
if (!dragging || e.pointerId !== pointerId) return;
ox += e.clientX - lastX;
oy += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
pushSample(e.clientX, e.clientY);
if (rafId == null) render();
});
function onPointerUp(e) {
if (!dragging || e.pointerId !== pointerId) return;
dragging = false;
try {
hero.releasePointerCapture(pointerId);
} catch {}
hero.classList.remove("dragging");
pointerId = null;
// Reduced motion: stop where you let go, no glide.
const v = reducedMotion ? { vx: 0, vy: 0 } : estimateVelocity();
vx = v.vx;
vy = v.vy;
startInertiaLoop();
}
hero.addEventListener("pointerup", onPointerUp);
hero.addEventListener("pointercancel", onPointerUp);
hero.addEventListener("dragstart", (e) => e.preventDefault());
let resizeRaf = null;
window.addEventListener("resize", () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => {
computeLayout();
for (const t of [...liveTiles.values(), ...tilePool]) {
t.el.style.width = `${tileW}px`;
t.el.style.height = `${tileH}px`;
}
render();
});
});
/* Entrance wave -------------------------------------------------------------- */
render();
function runEntrance() {
if (!entranceArmed) return;
let minC = Infinity;
let minR = Infinity;
for (const t of liveTiles.values()) {
minC = Math.min(minC, t.c);
minR = Math.min(minR, t.r);
}
// Delay = (dx + dy) · STEP + dx · SUBSTEP: a diagonal sweep, no two tiles at once.
let maxDelay = 0;
for (const t of liveTiles.values()) {
const dx = t.c - minC;
const dy = t.r - minR;
const delay = (dx + dy) * ENTRANCE_STEP_MS + dx * ENTRANCE_SUBSTEP_MS;
maxDelay = Math.max(maxDelay, delay);
t.el.style.setProperty("--enter-delay", `${delay}ms`);
}
const imgs = Array.from(grid.querySelectorAll("img"));
const loaded = imgs.map((img) =>
img.complete && img.naturalWidth > 0
? Promise.resolve()
: new Promise((res) => {
img.addEventListener("load", res, { once: true });
img.addEventListener("error", res, { once: true });
})
);
Promise.race([Promise.all(loaded), new Promise((res) => setTimeout(res, ENTRANCE_IMG_TIMEOUT_MS))]).then(() => {
entranceArmed = false; // tiles created later appear normally
requestAnimationFrame(() =>
requestAnimationFrame(() => {
for (const t of liveTiles.values()) {
if (t.el.classList.contains("entering")) t.el.classList.add("entered");
}
})
);
setTimeout(() => {
for (const t of [...liveTiles.values(), ...tilePool]) {
t.el.classList.remove("entering", "entered");
t.el.style.removeProperty("--enter-delay");
}
}, maxDelay + ENTRANCE_TRANSITION_MS + 200);
});
}
runEntrance();
})();
</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>
원본 구현 프롬프트
# Draggable Grid: an endless, staggered photo grid you drag in any direction **Goal:** build a full-screen, infinite grid of small square photos, offset per column, that you can drag in any direction with momentum on release, and that fades in as a slow diagonal wave on load. ## Visual behaviour 1. **Layout.** The whole viewport is a white canvas covered by a grid of square photo tiles (6px corners, `object-fit: cover`). Tiles are spaced far apart: 200px tiles with 110px gaps horizontally and vertically. Every **odd column is shifted down by half a row**, so the grid reads as a staggered, brick-like pattern. It extends infinitely in every direction. 2. **Photos.** With N photos, tile (column c, row r) shows photo `(c + K·r) mod N` (use a positive modulo for negative indices). Choose K coprime to N with K − 1 and K + 1 not multiples of N, so no tile has the same photo as any horizontal, vertical or diagonal neighbour (K = 5 for 12 photos, K = 7 for 25). 3. **Drag.** Press anywhere and drag: the grid follows the pointer 1:1 in both axes. Cursor `grab`, `grabbing` while dragging. No text or image selection, no native image drag. 4. **Momentum.** On release, estimate the velocity from the pointer samples of the last 80 ms (distance ÷ time between the first and last sample in that window), capped at 4 px/ms. The grid keeps gliding at that velocity, decaying by a factor of `0.94` per 60fps frame, made frame-rate independent: `v *= 0.94 ^ (dt / 16.667)`, with `dt` capped at 48 ms. The glide stops when the speed drops below 0.05 px/ms. Grabbing again during a glide stops it immediately. 5. **Virtualised.** Only tiles that are on screen (plus a margin of one column/row on each side) exist in the DOM. Tiles that leave the view go back into a pool and are reused for tiles entering on the other side. Each tile is positioned with `translate3d(x, y, 0)`. 6. **Entrance wave** (on load only): - Every tile on the first screen starts at opacity 0, 22px above its place. - Each tile gets a delay: `(dx + dy) × 140 ms + dx × 55 ms`, where dx and dy are its column and row index relative to the top-left-most rendered tile. This sweeps diagonally from top-left to bottom-right, and the extra 55 ms per column makes sure no two tiles start at the same moment. - After that delay, the photo fades to opacity 1 over **4000 ms** and slides down to its place over **4200 ms**, both with `cubic-bezier(0.2, 0.7, 0.2, 1)` (a soft ease-out). - The wave starts once the first screen of images has loaded (or after 1.5 s at the latest). - Tiles created later by dragging appear immediately, without the entrance. ## Exact values | What | Value | |---|---| | Tile size | 200px square (170px below 1024px viewport width, 140px below 640px) | | Gaps | 110px horizontal and vertical | | Column stagger | odd columns shifted down by 0.5 × (tile + gap) | | Corners | 6px | | Release velocity window | last 80 ms of pointer samples | | Velocity cap | 4 px/ms | | Friction | × 0.94 per 16.667 ms, frame-rate independent | | Stop threshold | 0.05 px/ms | | Entrance delay | (dx + dy) × 140 ms + dx × 55 ms | | Entrance motion | opacity 0 → 1 over 4000 ms, translateY −22px → 0 over 4200 ms, `cubic-bezier(0.2, 0.7, 0.2, 1)` | | Image wait | at most 1500 ms before the wave starts anyway | ## Markup and layout ```html <main class="hero" aria-label="Draggable photo grid. Drag in any direction."> <div class="grid"></div> <!-- tiles are created by JavaScript --> </main> ``` - `.hero`: `position: fixed; inset: 0; overflow: hidden; touch-action: none; user-select: none; cursor: grab`. The page itself doesn't scroll. - `.grid`: `position: absolute; inset: 0`. - Tile: `<div class="tile"><img></div>`, `position: absolute; top: 0; left: 0; overflow: hidden; border-radius: 6px; contain: layout paint size style; will-change: transform`. The image fills it with `object-fit: cover` and `pointer-events: none`. - Keep a pan offset (ox, oy). In each render, for every column `c` from `floor((-ox - tileW) / colStep) - 1` to `floor((-ox + W) / colStep) + 1`, compute the column's stagger and the visible row range the same way, and place tile (c, r) at `x = c·colStep + ox`, `y = r·rowStep + oy + stagger`, where `colStep = tileW + gapX` and `rowStep = tileH + gapY`. - Entrance: give first-screen tiles an `entering` class (hidden state with the transitions, delay from a `--enter-delay` custom property), then add `entered` two animation frames later to trigger the transitions. Remove both classes after `maxDelay + 4200 + 200` ms. ## Libraries - None. Pointer Events (`pointerdown` + `setPointerCapture`, `pointermove`, `pointerup`/`pointercancel`), a `requestAnimationFrame` loop for the glide, and CSS transitions for the entrance. - Optional: GSAP's Draggable + InertiaPlugin can replace the drag and momentum, but keep the friction and caps above so it feels the same. ## Responsive and reduced motion - Tile size steps down at 1024px (170px) and 640px (140px); gaps stay 110px. On resize, recompute the layout, resize all tiles and re-render. - Works with touch (pointer events with `touch-action: none`). - `prefers-reduced-motion: reduce`: no entrance wave (tiles appear at once) and no glide on release (the grid stops where you let go). Dragging still works. ## Acceptance criteria - The grid fills the screen in every direction, with odd columns offset by half a row, and never runs out while dragging. - No photo is repeated by any neighbouring tile, including diagonals. - Dragging moves the grid 1:1; a flick keeps it gliding and slowing down smoothly, the same at 60 Hz and 120 Hz. - On load the tiles fade in and drop 22px into place as a slow diagonal wave from the top-left to the bottom-right corner. - The DOM only ever holds about one screen's worth of tiles (tiles are recycled). - With reduced motion, tiles appear immediately and releasing a drag doesn't glide.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 55f53ab2df7a0995cbdccac16dbb702bf44dd9763a0a2108f1f466f49c78fa4b