모션·인터랙션 / Cadence
Connected Grid
스크롤 · 갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Connected Grid
As you scroll, each image grows out of the corner it shares with the previous one,
while the photo inside zooms out from 5×. Images already on screen at load grow in
once as a staggered intro. Scrubbed by scroll, so scrolling back reverses it.
Dependencies: GSAP 3.15 + ScrollTrigger, Lenis 1.3 (smooth scroll), from jsDelivr.
Tweak:
- Layout: on each .item, --r (row), --c (start column) and --s (span) on an 8-column grid
- INTRO_DURATION / INTRO_STAGGER: intro timing (1.6s per item, 0.14s apart)
- ScrollTrigger start/end: "top bottom-=15%" → "+=100%" (how much scroll one image takes)
- Easing: "power4" (scrub) / "power4.out" (intro); inner photo zoom: scale 5 → 1
- Lenis lerp: 0.15 (smoothness)
Based on "Connected Grid Layout Animation" by Codrops
https://tympanus.net/codrops/?p=73283 · https://github.com/codrops/ConnectedGrid
MIT License · Copyright (c) 2009 - 2022 Codrops
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connected Grid</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.css">
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #fff; }
.grid {
--gap: clamp(32px, 3.4vw, 48px);
display: grid;
grid-template-columns: 100%;
gap: 16px;
max-width: 1440px;
margin: 0 auto;
padding: 24px 24px 50vh;
}
.item { margin: 0; }
.img {
overflow: hidden;
aspect-ratio: 3 / 2;
border-radius: 6px;
}
.img-inner {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
@media (min-width: 53em) {
.grid {
grid-template-columns: repeat(8, 1fr);
gap: var(--gap);
}
/* Each item sits on its own row, starts at column --c and spans --s columns. */
.item {
grid-column: var(--c) / span var(--s, 1);
grid-row: var(--r);
}
}
</style>
</head>
<body>
<!-- Swap the picsum.photos placeholders for your own 3:2 images. -->
<section class="grid">
<figure class="item" style="--r: 1; --c: 1; --s: 4;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-1/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 2; --c: 5; --s: 3;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-2/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 3; --c: 3; --s: 2;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-3/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 4; --c: 1; --s: 2;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-4/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 5; --c: 3; --s: 5;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-5/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 6; --c: 2;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-6/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 7; --c: 3; --s: 3;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-7/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 8; --c: 6; --s: 2;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-8/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 9; --c: 1; --s: 5;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-9/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 10; --c: 6; --s: 3;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-10/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 11; --c: 4; --s: 2;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-11/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 12; --c: 1; --s: 3;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-12/1600/1067" alt=""></div></figure>
<figure class="item" style="--r: 13; --c: 4; --s: 5;"><div class="img"><img class="img-inner" src="https://picsum.photos/seed/cadence-13/1600/1067" alt=""></div></figure>
</section>
<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/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
<script>
gsap.registerPlugin(ScrollTrigger);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Smooth scrolling, driven by GSAP's ticker so ScrollTrigger stays in sync.
if (!reduceMotion) {
const lenis = new Lenis({ lerp: 0.15, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
}
const items = gsap.utils.toArray(".item");
const mm = gsap.matchMedia();
const INTRO_DURATION = 1.6; // s, per item
const INTRO_STAGGER = 0.14; // s between items
let introPlayed = false;
// Image grows from 0 and the photo inside zooms out from 5×, both from the shared corner.
function grow(item, originX, vars) {
return gsap
.timeline(vars)
.fromTo(item.querySelector(".img"), { scale: 0, transformOrigin: `${originX}% 0%` }, { scale: 1 })
.fromTo(item.querySelector(".img-inner"), { scale: 5, transformOrigin: `${originX}% 0%` }, { scale: 1 }, 0);
}
// Rebuilt when the layout switches between one column and the 8-column grid,
// because which side an item connects on depends on the layout.
mm.add(
{ grid: "(min-width: 53em)", motion: "(prefers-reduced-motion: no-preference)" },
({ conditions }) => {
if (!conditions.motion) return; // reduced motion: images simply stay in place
const onFirstScreen = (item) => item.getBoundingClientRect().top + window.scrollY < window.innerHeight;
let introIndex = 0;
items.forEach((item) => {
const previous = item.previousElementSibling;
// Left side: this item ends before the previous one starts → grow from its top-right corner.
const isLeftSide = previous && item.offsetLeft + item.offsetWidth <= previous.offsetLeft + 1;
const originX = isLeftSide ? 100 : 0;
if (onFirstScreen(item)) {
if (!introPlayed) {
grow(item, originX, {
defaults: { ease: "power4.out", duration: INTRO_DURATION },
delay: introIndex++ * INTRO_STAGGER,
});
}
return;
}
grow(item, originX, {
defaults: { ease: "power4" },
scrollTrigger: { trigger: item, start: "top bottom-=15%", end: "+=100%", scrub: true },
});
});
introPlayed = true;
}
);
</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>
원본 구현 프롬프트
# Connected Grid: scroll-scrubbed image grid where each image grows out of the previous one
**Goal:** build a staggered image grid where, as you scroll, each image grows out of the corner it shares with the image before it, so the grid seems to draw itself as one connected chain.
## Visual behaviour
1. The page is a vertical sequence of landscape images (3:2) on a white background. On desktop they sit on an 8-column grid, **one image per row**, each starting at a different column and spanning a different number of columns, so they zigzag down the page. Each image's top corner meets the bottom corner of the previous image across the row gap.
2. **Connection side.** For every image, compare it with the previous image:
- If it sits entirely to the **left** of the previous one (its right edge ≤ the previous image's left edge), it grows from its **top-right** corner.
- Otherwise it grows from its **top-left** corner.
- The first image grows from its top-left corner.
3. **The grow animation** (two transforms that run together):
- The image frame (a rounded, `overflow: hidden` box) scales from `0` to `1`, with `transform-origin` at the connection corner (`0% 0%` or `100% 0%`).
- The photo inside the frame scales from `5` to `1`, using the same transform origin. So while the frame opens, the photo zooms out inside it.
4. **Scroll-scrubbed.** Each image's animation is tied to scroll position, not time:
- It starts when the image's top reaches 15% above the bottom of the viewport (`top` of element at `bottom - 15%`).
- It ends one viewport height of scrolling later.
- Easing across that range: `power4` (strong ease-out: most of the growth happens early, then it settles). Equivalent cubic-bezier is roughly `cubic-bezier(0.22, 1, 0.36, 1)` (ease-out quint).
- Scrolling back up reverses it exactly: the images shrink back into their corners.
5. **Intro.** Images that are already within the first screen at page load don't wait for scroll. They play the same grow animation once, on a timer:
- Duration 1.6 s per image, easing `power4.out`.
- Staggered 0.14 s apart, in document order.
- After that they stay at full size (no scroll binding).
6. **Smooth scrolling** with a lerp-based smooth scroll (lerp `0.15`, smooth wheel on), synchronised with the scroll-animation engine so the scrub follows the smoothed position.
## Exact values
| What | Value |
|---|---|
| Frame scale | 0 → 1 |
| Inner photo scale | 5 → 1 |
| Transform origin | `0% 0%` (grows right/down) or `100% 0%` (grows left/down) |
| Scroll start | element top at viewport bottom − 15% |
| Scroll length | 100% of the viewport height |
| Scrub easing | `power4` (ease-out quint) |
| Intro | 1.6 s per image, `power4.out`, 0.14 s stagger |
| Smooth scroll | lerp 0.15 |
| Image ratio | 3:2, `object-fit: cover`, border radius 6px |
| Grid gap | `clamp(32px, 3.4vw, 48px)` on desktop, 16px on mobile |
| Bottom padding | 50vh, so the last images have room to finish growing |
## Markup and layout
```html
<section class="grid">
<figure class="item" style="--r:1; --c:1; --s:4">
<div class="img"><img class="img-inner" src="…" alt="…"></div>
</figure>
<figure class="item" style="--r:2; --c:5; --s:3"> … </figure>
…
</section>
```
- Desktop (≥ 53em / 848px): `grid-template-columns: repeat(8, 1fr)`. Each item gets `grid-row: var(--r)` and `grid-column: var(--c) / span var(--s, 1)`.
- A layout that reads well (row: start column / span): 1: 1/4 · 2: 5/3 · 3: 3/2 · 4: 1/2 · 5: 3/5 · 6: 2/1 · 7: 3/3 · 8: 6/2 · 9: 1/5 · 10: 6/3 · 11: 4/2 · 12: 1/3 · 13: 4/5. Mix wide and narrow images, and alternate sides so the chain zigzags.
- `.img`: `overflow: hidden; aspect-ratio: 3/2; border-radius: 6px`. `.img-inner`: `width: 100%; height: 100%; object-fit: cover; display: block`.
- Add generous bottom padding (around 50vh) to the grid.
## Libraries
- **GSAP 3** with **ScrollTrigger**: one timeline per image with two `fromTo` tweens at position 0, `scrollTrigger: { trigger: item, start: "top bottom-=15%", end: "+=100%", scrub: true }`, `defaults: { ease: "power4" }`. Intro items use a plain timeline with `defaults: { ease: "power4.out", duration: 1.6 }` and `delay: index * 0.14`.
- **Lenis** for smooth scroll: `new Lenis({ lerp: 0.15, smoothWheel: true })`, call `ScrollTrigger.update` on Lenis's scroll event, drive `lenis.raf` from `gsap.ticker`, and set `gsap.ticker.lagSmoothing(0)`.
- Use `gsap.matchMedia()` so the animations are rebuilt when the layout switches between one and eight columns (the connection side depends on the layout).
- **Plain alternative:** CSS scroll-driven animations (`animation-timeline: view()` with `animation-range: entry 15% …`) can drive both scales per item. Set the transform origin per item in JS or with a class, and use `cubic-bezier(0.22, 1, 0.36, 1)` as the easing. Skip smooth scrolling.
## Responsive and reduced motion
- Below 53em: a single column of full-width images with a 16px gap. Each image still grows (from its top-left corner) as it scrolls in.
- `prefers-reduced-motion: reduce`: no smooth scrolling and no animations. All images are shown at full size.
## Acceptance criteria
- On load, the images on the first screen grow in one after another (0.14 s apart), each from its connection corner, with the photo zooming out from 5× inside the frame.
- While scrolling down, each further image starts growing as it passes 15% above the bottom of the viewport. It is fully grown after one more viewport height of scrolling, always from the corner touching the previous image.
- Scrolling up reverses the growth smoothly, with no jumps.
- An image that sits to the left of its predecessor grows from its top-right corner. All others grow from top-left.
- Resizing across the 53em breakpoint rebuilds the animations correctly.
- With reduced motion enabled, everything is visible and static.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 84c7fd21a780c3f9ec0b88cd281191f5894361656ff21d5b51eec437fde8123b