모션·인터랙션 / Cadence
Push Grid
갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Push Grid
A scattered checkerboard of photos fills the screen. Hovering a tile darkens it and
fades in a thin white plus; every tile drifts gently towards the pointer at its own
depth (parallax). Click a photo: it flips (GSAP Flip) to full screen while every other
tile is pushed off the edge on the side it sits relative to the clicked one (north,
south, west or east). Click the full-screen photo (or press Escape) to bring
everything back.
Dependencies: GSAP 3.15 + Flip plugin, from jsDelivr.
Tweak:
- animationDefaults: duration 0.9s, ease "expo" (= expo.out) for both flips
- --cgap / --rgap (5%) and --rows / --columns (7) in .pg-main: grid gaps and size
- .pos-1 … .pos-13: where the tiles sit (the outer ring is off-screen, for pushed tiles)
- Hover cue: overlay rgba(0,0,0,.3), plus 28px × 1.5px, 0.45s fade, scale 0.7 → 1
- POINTER_SHIFT (14px), POINTER_LERP (0.06), DEPTHS: parallax strength, smoothing, per-tile depth
Based on "Grid Item Push Animation" (Demo 1) by Codrops, after a concept by Rémi Clauss
https://tympanus.net/Development/PushGridItems/ · https://github.com/codrops/PushGridItems
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>Push Grid</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #fff; }
.pg-stage {
position: fixed;
inset: 0;
overflow: hidden;
}
/* The grid is one item + one gap larger than the stage on every side,
so row 1, row 7, column 1 and column 7 sit just off-screen. */
.pg-main {
display: grid;
position: relative;
grid-template-areas: "main";
grid-template-columns: 100%;
grid-template-rows: 100%;
--cgap: 5%;
--rgap: 5%;
--rows: 7;
--columns: 7;
--item-width: calc((100% + (var(--columns) - 1) * var(--cgap)) / var(--columns));
--item-height: calc((100% + (var(--rows) - 1) * var(--rgap)) / var(--rows));
width: calc(100% + var(--item-width) * 2 + var(--cgap) * 2);
height: calc(100% + var(--item-height) * 2 + var(--rgap) * 2);
left: calc(-1 * var(--item-width) - var(--cgap));
top: calc(-1 * var(--item-height) - var(--rgap));
}
.pg-grid {
grid-area: main;
display: grid;
grid-auto-flow: column;
grid-template-columns: repeat(var(--columns), 1fr);
grid-template-rows: repeat(var(--rows), 1fr);
grid-row-gap: var(--rgap);
grid-column-gap: var(--cgap);
pointer-events: none;
}
.pg-item {
width: 100%;
height: 100%;
position: relative;
will-change: transform, opacity;
}
/* Checkerboard of 13 tiles across the visible 5×5 cells */
.pos-1 { grid-area: 2 / 2 / 3 / 3; }
.pos-2 { grid-area: 4 / 2 / 5 / 3; }
.pos-3 { grid-area: 6 / 2 / 7 / 3; }
.pos-4 { grid-area: 3 / 3 / 4 / 4; }
.pos-5 { grid-area: 5 / 3 / 6 / 4; }
.pos-6 { grid-area: 2 / 4 / 3 / 5; }
.pos-7 { grid-area: 4 / 4 / 5 / 5; }
.pos-8 { grid-area: 6 / 4 / 7 / 5; }
.pos-9 { grid-area: 3 / 5 / 4 / 6; }
.pos-10 { grid-area: 5 / 5 / 6 / 6; }
.pos-11 { grid-area: 2 / 6 / 3 / 7; }
.pos-12 { grid-area: 4 / 6 / 5 / 7; }
.pos-13 { grid-area: 6 / 6 / 7 / 7; }
/* Pushed states: move the item to the off-screen outer row / column */
.pos-north { grid-row: 1; }
.pos-south { grid-row: var(--rows); }
.pos-west { grid-column: 1; }
.pos-east { grid-column: var(--columns); }
.pg-img {
width: 100%;
height: 100%;
background-color: #e6e6e6;
background-size: cover;
background-position: 50% 50%;
border-radius: 6px;
position: relative;
pointer-events: auto;
cursor: pointer;
outline: none;
will-change: transform;
}
/* Hover cue: the tile darkens and a thin white plus fades in at its centre. */
.pg-img::before,
.pg-img::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 0.45s ease, transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.pg-img::before { background: rgba(0, 0, 0, 0.3); }
.pg-img::after {
background:
linear-gradient(#fff, #fff) center / 28px 1.5px no-repeat,
linear-gradient(#fff, #fff) center / 1.5px 28px no-repeat;
transform: scale(0.7);
}
@media (hover: hover) and (pointer: fine) {
.pg-grid .pg-img:hover::before,
.pg-grid .pg-img:hover::after {
opacity: 1;
transform: none;
}
}
.pg-img:focus-visible { box-shadow: 0 0 0 1px #fff, 0 0 0 2px #000; }
/* Full-screen target: bleeds 4px past the stage */
.pg-fullscreen {
position: absolute;
width: calc(100% + 8px);
height: calc(100% + 8px);
top: -4px;
left: -4px;
pointer-events: none;
}
.pg-fullscreen .pg-img { border-radius: 0; } /* Flip animates the radius */
@media (prefers-reduced-motion: reduce) {
.pg-img::after { transform: none; }
}
</style>
</head>
<body>
<!-- Placeholder 3:2 photos: swap the seeds in PHOTOS (in the script) for your own image
URLs. A small version fills the grid, a large one is layered on top when a photo opens. -->
<div class="pg-stage">
<main class="pg-main">
<div class="pg-grid">
<div class="pg-item pos-1"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 1"></div></div>
<div class="pg-item pos-2"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 2"></div></div>
<div class="pg-item pos-3"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 3"></div></div>
<div class="pg-item pos-4"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 4"></div></div>
<div class="pg-item pos-5"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 5"></div></div>
<div class="pg-item pos-6"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 6"></div></div>
<div class="pg-item pos-7"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 7"></div></div>
<div class="pg-item pos-8"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 8"></div></div>
<div class="pg-item pos-9"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 9"></div></div>
<div class="pg-item pos-10"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 10"></div></div>
<div class="pg-item pos-11"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 11"></div></div>
<div class="pg-item pos-12"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 12"></div></div>
<div class="pg-item pos-13"><div class="pg-img" role="button" tabindex="0" aria-label="Photo 13"></div></div>
</div>
</main>
<div class="pg-fullscreen"></div>
</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/Flip.min.js"></script>
<script>
gsap.registerPlugin(Flip);
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const POSITION_CLASSES = { NORTH: "pos-north", SOUTH: "pos-south", WEST: "pos-west", EAST: "pos-east" };
// Both flips: 0.9s, "expo" (= expo.out). Instant for reduced motion.
const animationDefaults = { duration: reducedMotion ? 0 : 0.9, ease: "expo" };
// Placeholder media (3:2). Swap for your own: a small and a large URL per photo.
const smSrc = (i) => `https://picsum.photos/seed/cadence-${i + 1}/800/533`;
const xlSrc = (i) => `https://picsum.photos/seed/cadence-${i + 1}/2400/1600`;
const gridElement = document.querySelector(".pg-grid");
const gridItems = Array.from(gridElement.querySelectorAll(".pg-item"));
const gridImages = gridElement.querySelectorAll(".pg-img");
const fullscreenElement = document.querySelector(".pg-fullscreen");
let isFullscreen = false;
let isAnimating = false; // ignore clicks while a flip is running
let activeImage = null;
gridImages.forEach((el, i) => {
el.dataset.index = i;
el.style.backgroundImage = `url(${smSrc(i)})`;
});
// Sharper photo when full-screen: the large image painted over the loaded small one
const xlRequested = new Set();
const preloadXL = (gridImage) => {
const i = gridImage.dataset.index;
if (xlRequested.has(i)) return;
xlRequested.add(i);
new Image().src = xlSrc(+i);
};
const upgradeToXL = (gridImage) => {
preloadXL(gridImage);
const i = +gridImage.dataset.index;
gridImage.style.backgroundImage = `url(${xlSrc(i)}), url(${smSrc(i)})`;
};
// Flip the clicked image between its grid cell and the full-screen layer
const flipImage = (gridItem, gridImage) => {
gsap.set(gridItem, { zIndex: 99 });
const state = Flip.getState(gridImage, { props: "borderRadius" });
if (isFullscreen) gridItem.appendChild(gridImage);
else fullscreenElement.appendChild(gridImage);
gridImage.focus({ preventScroll: true });
Flip.from(state, {
...animationDefaults,
scale: true,
prune: true,
onComplete: () => {
if (isFullscreen) gsap.set(gridItem, { zIndex: "auto" });
isFullscreen = !isFullscreen;
isAnimating = false;
},
});
};
// Which side of the clicked item another item sits on
const determinePositionClass = (itemRect, clickedRect) => {
if (itemRect.bottom < clickedRect.top) return POSITION_CLASSES.NORTH;
if (itemRect.top > clickedRect.bottom) return POSITION_CLASSES.SOUTH;
if (itemRect.right < clickedRect.left) return POSITION_CLASSES.WEST;
if (itemRect.left > clickedRect.right) return POSITION_CLASSES.EAST;
return "";
};
// Push (or bring back) every other item to the off-screen ring on its side
const moveOtherItems = (gridItem) => {
const clickedRect = gridItem.getBoundingClientRect();
const otherGridItems = gridItems.filter((item) => item !== gridItem);
const state = Flip.getState(otherGridItems);
otherGridItems.forEach((item) => {
const classname = determinePositionClass(item.getBoundingClientRect(), clickedRect);
if (classname) item.classList.toggle(classname, !isFullscreen);
});
Flip.from(state, { ...animationDefaults, scale: true, prune: true });
};
const toggleImage = (gridImage) => {
if (isAnimating) return;
if (isFullscreen && gridImage !== activeImage) return;
isAnimating = true;
const gridItem = gridItems[gridImage.dataset.index];
if (!isFullscreen) {
activeImage = gridImage;
upgradeToXL(gridImage);
}
flipImage(gridItem, gridImage);
moveOtherItems(gridItem);
};
const initEvents = () => {
gridImages.forEach((gridImage) => {
gridImage.addEventListener("click", () => toggleImage(gridImage));
gridImage.addEventListener("pointerenter", () => preloadXL(gridImage));
gridImage.addEventListener("focus", () => preloadXL(gridImage));
gridImage.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
toggleImage(gridImage);
}
});
});
window.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && isFullscreen && activeImage) toggleImage(activeImage);
});
};
// Pointer parallax: every tile drifts towards the pointer, scaled by its own depth.
// Uses the independent CSS `translate` property, so it never fights Flip's transforms.
const POINTER_SHIFT = 14; // px: max drift of the deepest tile
const POINTER_LERP = 0.06; // smoothing per frame (60fps); lower = softer
const DEPTHS = [1, 0.7, 0.85, 0.6, 0.95, 0.75, 0.55, 0.9, 0.65, 1, 0.8, 0.6, 0.9];
const initParallax = () => {
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
if (reducedMotion || !finePointer) return;
const target = { x: 0, y: 0 }; // pointer, -1…1 from the centre
const current = { x: 0, y: 0 };
window.addEventListener("pointermove", (ev) => {
if (ev.pointerType !== "mouse") return;
target.x = (ev.clientX / window.innerWidth) * 2 - 1;
target.y = (ev.clientY / window.innerHeight) * 2 - 1;
});
document.documentElement.addEventListener("pointerleave", () => { target.x = 0; target.y = 0; });
gsap.ticker.add((time, deltaTime) => {
// Rest at the centre while a photo is open or flipping
const settled = isFullscreen || isAnimating;
const tx = settled ? 0 : target.x;
const ty = settled ? 0 : target.y;
const k = 1 - Math.pow(1 - POINTER_LERP, deltaTime / (1000 / 60)); // frame-rate independent
current.x += (tx - current.x) * k;
current.y += (ty - current.y) * k;
if (Math.abs(current.x) < 0.0005 && Math.abs(current.y) < 0.0005 && settled) current.x = current.y = 0;
gridItems.forEach((item, i) => {
const d = DEPTHS[i % DEPTHS.length] * POINTER_SHIFT;
item.style.translate = `${(current.x * d).toFixed(2)}px ${(current.y * d).toFixed(2)}px`;
});
});
};
// Preload the grid images, then start
Promise.all(Array.from(gridImages, (el, i) => new Promise((resolve) => {
const img = new Image();
img.onload = img.onerror = resolve;
img.src = smSrc(i);
}))).then(() => {
initEvents();
initParallax();
});
</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>
원본 구현 프롬프트
# Push Grid: a photo flips to full screen while every other tile is pushed off the nearest edge
**Goal:** build a full-screen scattered photo grid where clicking a photo animates it to full screen while all other tiles slide off-screen on the side they sit relative to it, with a hover cue and a gentle depth parallax that follows the pointer.
## Visual behaviour
1. **Layout.** A full-viewport stage (no scrolling, `overflow: hidden`) holds a 7 × 7 grid that is one cell + one gap larger than the viewport on every side, so the outer ring of cells sits just off-screen. 13 landscape (3:2) photos sit in a checkerboard across the visible 5 × 5 cells: (row / column) 2/2, 4/2, 6/2, 3/3, 5/3, 2/4, 4/4, 6/4, 3/5, 5/5, 2/6, 4/6, 6/6. Tiles are `background-size: cover`, 6px rounded corners, light grey `#e6e6e6` behind while loading. White page.
2. **Hover cue** (hover-capable fine pointers only). The hovered tile darkens with a 30% black overlay, and a thin white plus (two 1.5 px lines, 28 px long, centred) fades in, scaling from 0.7 to 1. Both over 0.45 s: opacity `ease`, scale `cubic-bezier(0.22, 1, 0.36, 1)`. It fades back out on leave.
3. **Pointer parallax** (fine pointers only). The pointer position, normalised to −1…1 from the viewport centre, is smoothed every frame (lerp 0.06 at 60 fps, made frame-rate independent with `k = 1 − (1 − 0.06)^(dt / 16.67ms)`). Every tile is offset towards the pointer by `pointer × 14px × depth`, with a fixed per-tile depth (1, 0.7, 0.85, 0.6, 0.95, 0.75, 0.55, 0.9, 0.65, 1, 0.8, 0.6, 0.9 in tile order), so the grid feels layered. Use the independent CSS `translate` property so it doesn't conflict with the flip transforms. While a photo is open or animating, the target is 0 and the tiles ease back to their rest positions. Pointer leaving the page also resets the target to 0.
4. **Open (click a tile).**
- The clicked image element is moved into a full-screen layer (the viewport plus 4px bleed on every side) and animated from its old box to the new one with a FLIP animation (scale-based), including its corner radius from 6px to 0. Its grid cell gets `z-index: 99` so it flies over the others.
- At the same time every other tile is classified by where it sits relative to the clicked tile: entirely above → **north**, entirely below → **south**, otherwise entirely left → **west**, entirely right → **east** (checked in that order; the bounding boxes decide). Each gets a class that moves it to the off-screen outer row or column on that side (`grid-row: 1`, `grid-row: 7`, `grid-column: 1` or `grid-column: 7`). That layout change is FLIP-animated too, so the tiles slide out past the edge.
- Both animations: **0.9 s, `expo.out`** (≈ `cubic-bezier(0.16, 1, 0.3, 1)`).
- When opening, swap the photo's background to a high-resolution version layered over the small one (`url(large), url(small)`), and preload the large version on hover or focus.
5. **Close (click the full-screen photo, or press Escape).** The exact reverse: the image flips back into its cell (radius back to 6px), the push classes are removed and the tiles slide back in, same 0.9 s `expo.out`. Reset the cell's z-index when done.
6. Ignore clicks while a flip is running, and while open ignore clicks on anything but the open photo.
## Exact values
| What | Value |
|---|---|
| Grid | 7 × 7, gaps 5% (columns and rows), outer ring off-screen |
| Item size | `(100% + 6 × 5%) / 7` of the stage, per axis |
| Grid box | stage + 2 items + 2 gaps on each axis, offset by −(item + gap) |
| Flip | 0.9 s, `expo.out`, scale-based, also animates border-radius 6px ↔ 0 |
| Full-screen layer | `calc(100% + 8px)` square to the stage, offset −4px |
| Hover overlay | `rgba(0,0,0,0.3)`, 0.45 s `ease` |
| Plus | 28 × 1.5 px white lines, scale 0.7 → 1, 0.45 s `cubic-bezier(0.22, 1, 0.36, 1)` |
| Parallax | max 14px × depth (0.55–1), lerp 0.06 per 60 fps frame |
## Markup and structure
```html
<div class="stage"> <!-- fixed, inset 0, overflow hidden -->
<main class="main"> <!-- oversized wrapper grid -->
<div class="grid"> <!-- 7×7, grid-auto-flow: column -->
<div class="item pos-1"><div class="img" role="button" tabindex="0" style="background-image:url(…)"></div></div>
… 13 items …
</div>
</main>
<div class="fullscreen"></div> <!-- target layer for the open photo -->
</div>
```
- Each `.pos-N` sets `grid-area` for its cell; `.pos-north/south/west/east` override just the row or column.
- The grid itself has `pointer-events: none`; the images have `pointer-events: auto`.
- Draw the hover overlay and plus with `::before` / `::after` on the image, scoped to images inside the grid so the open photo doesn't show them.
## Libraries
- **GSAP 3** with the **Flip** plugin: `Flip.getState(el, { props: "borderRadius" })` → move the element in the DOM → `Flip.from(state, { duration: 0.9, ease: "expo", scale: true, prune: true })`. For the other tiles: `Flip.getState(otherItems)` → toggle the push classes → `Flip.from` with the same options. Drive the parallax from `gsap.ticker`.
- **Plain alternative:** implement FLIP by hand (measure first/last boxes with `getBoundingClientRect`, apply the inverse transform, then animate to identity with the Web Animations API and `cubic-bezier(0.16, 1, 0.3, 1)`), or use View Transitions with a `view-transition-name` per tile.
## Responsive and reduced motion
- The grid is fully relative (percent gaps and sizes), so it fills any viewport; tiles keep their checkerboard positions and stretch with the viewport's ratio.
- Touch devices: no hover cue and no parallax; tap opens and closes.
- Keyboard: tiles are focusable; Enter or Space toggles, Escape closes. Keep focus on the image after it moves in the DOM.
- `prefers-reduced-motion: reduce`: flips are instant (duration 0), no parallax, the plus doesn't scale.
## Acceptance criteria
- Hovering a tile darkens it and shows a small white plus; the tiles drift a few pixels towards the pointer, each by a different amount.
- Clicking a tile grows it smoothly to full bleed (corners go square) while the other tiles fly off the nearest outer edge, all in 0.9 s with a fast start and soft landing.
- Clicking the open photo or pressing Escape reverses everything exactly.
- Rapid clicks never break the state.
- With reduced motion, opening and closing are instant and nothing drifts.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 5a714803e59551ec35acb299749d9f8d1a63795507e506203dbda7191dd5e0b3