모션·인터랙션 / Cadence
Contact Sheet
스크롤 · 갤러리 · 이미지 · 3D 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Contact Sheet
A row of five photo frames (a contact sheet) that unfolds into one full-screen
photograph as you scroll. A sticky hero sits inside a 230svh scroll track. Scroll
progress is lerped (0.13 per frame) and drives the centre frame from its slot in the
row to full screen (left, top, width, height and corner radius, smoothstepped over the
first 90% of the scroll), while the four side frames fly out in 3D (translateX ±45% of
the hero width, ±28px up/down, translateZ −200px, rotateY ±35°) and fade out. Between
66% and 94% a block of copy rises in over the full-screen photo.
Intro: the centre frame opens through a clip-path aperture (inset 50% 0 → 0) while the
side frames converge out of the centre. While the page rests at the top, the intro
replays after a 2.5s hold; a click on the empty stage replays it at once.
Clicking the centre frame scrolls to the end (unfold); Escape scrolls back.
Clicking a side frame (or ← / →, or a horizontal swipe on the centre frame) swaps the
study: the old photo is wiped away upwards with a clip-path (inset 0 → inset 0 0 100% 0)
and the side frames always show the two studies that are not active.
Dependencies: none (plain HTML, CSS and JS).
Tweak:
- LERP 0.13: how fast the frames catch up with the scroll (per frame)
- GROW_END 0.9: the centre frame is full screen at 90% of the scroll
- COPY_IN [0.66, 0.28]: the copy rises in from 66% over 28% of the scroll (COPY_RISE 35px)
- FLY_X 0.45 (× hero width), FLY_Y 28px, FLY_Z 200px, FLY_ROTATE 35deg: side frames
- RADIUS 6px: frame corners (ease to square at full screen)
- WIPE: 1100ms cubic-bezier(.65,0,.25,1)
- Intro: centre 1500ms, side frames 1900ms with a 170ms + |i − 1.5| × 90ms delay,
both INTRO_EASE cubic-bezier(.2,.7,.2,1)
- LOOP / HOLD_MS: replay the intro after a 2500ms hold while the page is at the top
- .cs-track height 230svh: the scroll distance (one screen of hero + 130svh of scroll)
- Frame positions: .cs-main and .cs-card-0…4 in the CSS (percentages of the hero)
Based on the hero of the Interval site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contact Sheet</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 400;
-webkit-font-smoothing: antialiased;
}
p, h2 { margin: 0; font-weight: 400; }
/* The scroll distance: the hero pins for (230svh − 100svh) of scroll */
.cs-track {
height: 230svh;
}
.cs-hero {
position: sticky;
top: 0;
height: 100svh;
min-height: 650px;
overflow: hidden;
isolation: isolate;
}
.cs-hero em {
font-style: normal;
color: #8e8e8e;
}
.cs-eyebrow {
font-size: 10px;
font-weight: 500;
line-height: 1.6;
letter-spacing: 0.25em;
text-transform: uppercase;
}
/* The contact sheet: five absolutely placed frames in a 3D space */
.cs-sheet {
position: absolute;
inset: 0;
perspective: 1800px;
pointer-events: none;
}
.cs-card,
.cs-main {
position: absolute;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 6px;
background: #e6e6e6;
pointer-events: auto;
will-change: transform, opacity;
backface-visibility: hidden;
}
.cs-card img,
.cs-main > img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
.cs-card {
z-index: 2;
top: 40%;
width: 17%;
height: 35%;
cursor: pointer;
transform-origin: center;
}
.cs-card img {
filter: saturate(0.8);
transition: transform 1.6s cubic-bezier(0.22, 0.75, 0.2, 1);
}
.cs-card:hover img {
transform: scale(1.06);
}
.cs-card-0 { left: 1%; top: 36.5%; width: 13%; height: 27%; } /* far left */
.cs-card-1 { left: 15.2%; top: 31%; width: 17%; height: 38%; } /* near left */
.cs-card-3 { left: 67.8%; top: 31%; width: 17%; height: 38%; } /* near right */
.cs-card-4 { left: 86%; top: 36.5%; width: 13%; height: 27%; } /* far right */
/* The centre frame. Its left / top / width / height are lerped by the script. */
.cs-main {
z-index: 4;
left: 33.4%;
top: 27%;
width: 33.2%;
height: 46%;
cursor: pointer;
touch-action: pan-y;
transform-origin: center;
}
.cs-main > img {
position: absolute;
inset: 0;
object-position: 50% 40%;
}
/* Darkens the photo under the copy; the script drives its gradient */
.cs-shade {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
background: linear-gradient(transparent 60%, rgb(0 0 0 / 0.19));
opacity: 0.8;
}
.is-immersive .cs-main {
cursor: default;
}
/* Copy that rises in over the full-screen frame */
.cs-immersive {
position: absolute;
bottom: 20%;
left: 7%;
z-index: 8;
color: #fff;
opacity: 0;
transform: translateY(40px);
pointer-events: none;
}
.cs-immersive .cs-eyebrow {
color: rgb(255 255 255 / 0.8);
}
.cs-immersive h2 {
margin: 25px 0;
font-size: clamp(48px, 6vw, 108px);
font-weight: 400;
line-height: 1;
letter-spacing: -0.04em;
}
.cs-immersive em {
color: rgb(255 255 255 / 0.7);
}
.cs-immersive > p:last-child {
font-size: 18px;
}
/* Copy of the old photo, wiped away when the study changes */
.cs-transition-image {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 40%;
}
/* Short desktop screens */
@media (max-height: 690px) and (min-width: 761px) {
.cs-hero { min-height: 550px; }
.cs-main { top: 28.5%; height: 43%; }
.cs-card-1,
.cs-card-3 { top: 32.5%; height: 35%; }
}
/* Phones: a wider centre frame, the near frames bleed off the edges, no far frames */
@media (max-width: 760px) {
.cs-hero { min-height: 590px; }
.cs-eyebrow { font-size: 8px; letter-spacing: 0.15em; }
.cs-main { left: 22%; top: 29%; width: 56%; height: 42%; }
.cs-card-1 { left: -10%; top: 34.5%; width: 29%; height: 31%; }
.cs-card-3 { left: 81%; top: 34.5%; width: 29%; height: 31%; }
.cs-card-far { display: none; }
.cs-immersive { left: 7%; right: 7%; bottom: 22%; }
.cs-immersive h2 { font-size: 44px; }
.cs-immersive > p:last-child { max-width: 240px; font-size: 15px; }
}
</style>
</head>
<body>
<!-- A tall scroll track with a sticky hero. Swap the picsum.photos placeholders (3:2)
here AND in the STUDIES list in the script for your own photos. The side frames'
data-study is the index of the study they show. -->
<main class="cs-track">
<section class="cs-hero" aria-label="Contact sheet">
<div class="cs-sheet">
<button class="cs-card cs-card-far cs-card-0" data-study="2" aria-label="Show study 03, Gallery"><img src="https://picsum.photos/seed/cadence-3/1200/800" alt="" draggable="false"></button>
<button class="cs-card cs-card-near cs-card-1" data-study="1" aria-label="Show study 02, Linen"><img src="https://picsum.photos/seed/cadence-2/1200/800" alt="" draggable="false"></button>
<div class="cs-main" id="main-card">
<img id="hero-image" src="https://picsum.photos/seed/cadence-1/2400/1600" alt="Study 01" style="object-position: 50% 40%" fetchpriority="high" draggable="false">
<div class="cs-shade"></div>
</div>
<button class="cs-card cs-card-near cs-card-3" data-study="2" aria-label="Show study 03, Gallery"><img src="https://picsum.photos/seed/cadence-3/1200/800" alt="" draggable="false"></button>
<button class="cs-card cs-card-far cs-card-4" data-study="1" aria-label="Show study 02, Linen"><img src="https://picsum.photos/seed/cadence-2/1200/800" alt="" draggable="false"></button>
</div>
<div class="cs-immersive" aria-hidden="true">
<p class="cs-eyebrow" id="scene-label">Study 01 — Dune</p>
<h2 id="scene-title">Sand, wind<br>and a <em>long coat.</em></h2>
<p id="scene-description">A figure on the ridge, held by the light.</p>
</div>
</section>
</main>
<script>
(() => {
"use strict";
/* Constants --------------------------------------------------------------- */
const LERP = 0.13; // progress catch-up per frame
const GROW_END = 0.9; // the frame is full screen at 90% progress
const COPY_IN = [0.66, 0.28]; // immersive copy: start, duration
const IMMERSIVE_AT = 0.66;
const FLY_X = 0.45; // side frames: × hero width
const FLY_Y = 28; // px, alternating up / down
const FLY_Z = 200; // px
const FLY_ROTATE = 35; // deg
const COPY_RISE = 35; // px
const RADIUS = 6; // corner radius of the frames
const WIPE = { duration: 1100, easing: "cubic-bezier(.65,0,.25,1)" };
const INTRO_EASE = "cubic-bezier(.2,.7,.2,1)";
const LOOP = true; // replay the intro after HOLD_MS while the page sits at the top
const HOLD_MS = 2500;
// The three studies. Swap the picsum.photos placeholders for your own 3:2 photos:
// `src` is the large file for the full-screen frame, `thumb` a smaller one for the
// side frames. `position` is each photo's object-position (its focal point), so
// portrait screens keep the subject in frame. `name`, `title`, `line` and `alt` are
// placeholder copy for the full-screen state: write your own for your photos.
const STUDIES = [
{ name: "Dune", src: "https://picsum.photos/seed/cadence-1/2400/1600", thumb: "https://picsum.photos/seed/cadence-1/1200/800", position: "50% 40%", title: "Sand, wind<br>and a <em>long coat.</em>", line: "A figure on the ridge, held by the light.", alt: "Study 01" },
{ name: "Linen", src: "https://picsum.photos/seed/cadence-2/2400/1600", thumb: "https://picsum.photos/seed/cadence-2/1200/800", position: "50% 50%", title: "Soft light,<br>slow <em>hours.</em>", line: "Linen, a white wall and a morning that waits.", alt: "Study 02" },
{ name: "Gallery", src: "https://picsum.photos/seed/cadence-3/2400/1600", thumb: "https://picsum.photos/seed/cadence-3/1200/800", position: "50% 50%", title: "An empty room,<br>a full <em>moment.</em>", line: "Stone floor, white walls, one quiet figure.", alt: "Study 03" },
];
/* Elements and state ------------------------------------------------------ */
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const reducedQuery = matchMedia("(prefers-reduced-motion: reduce)");
const track = $(".cs-track");
const hero = $(".cs-hero");
const main = $("#main-card");
const photo = $("#hero-image");
const shade = $(".cs-shade");
const cards = $$(".cs-card"); // far left, near left, near right, far right
const scene = $(".cs-immersive");
let active = 0, progress = 0, target = 0, frame = 0, base = {}, sequence = 0;
let introAnimations = [], introTimer = 0, introRunning = false;
const clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (t) => t * t * (3 - 2 * t);
const maxScroll = () => Math.max(1, track.offsetHeight - hero.offsetHeight);
/* Layout ------------------------------------------------------------------ */
// Read the centre frame's resting box from the CSS (the start of the lerp).
function measure() {
["left", "top", "width", "height"].forEach((k) => main.style.removeProperty(k));
const style = getComputedStyle(main);
base = { x: parseFloat(style.left), y: parseFloat(style.top), w: parseFloat(style.width), h: parseFloat(style.height) };
target = clamp(scrollY / maxScroll());
requestRender();
}
function requestRender() {
if (!frame) frame = requestAnimationFrame(render);
}
function render() {
frame = 0;
progress = reducedQuery.matches ? target : lerp(progress, target, LERP);
if (Math.abs(progress - target) < 0.0002) progress = target;
const p = smooth(clamp(progress / GROW_END));
const copyIn = smooth(clamp((progress - COPY_IN[0]) / COPY_IN[1]));
const W = hero.clientWidth, H = hero.clientHeight;
// Centre frame: from its slot to the full hero, corners easing to square.
main.style.left = `${lerp(base.x, 0, p)}px`;
main.style.top = `${lerp(base.y, 0, p)}px`;
main.style.width = `${lerp(base.w, W, p)}px`;
main.style.height = `${lerp(base.h, H, p)}px`;
main.style.borderRadius = `${RADIUS * (1 - p)}px`;
// Side frames: fly outwards, back and turned away, fading out by p = 0.625.
cards.forEach((card, i) => {
const direction = i < 2 ? -1 : 1;
card.style.transform = `translate3d(${direction * p * W * FLY_X}px,${p * (i % 2 ? FLY_Y : -FLY_Y)}px,${-p * FLY_Z}px) rotateY(${direction * p * FLY_ROTATE}deg)`;
card.style.opacity = 1 - clamp(p * 1.6);
card.inert = p > 0.3;
});
scene.style.opacity = copyIn;
scene.style.transform = `translateY(${(1 - copyIn) * COPY_RISE}px)`;
scene.setAttribute("aria-hidden", String(copyIn < 0.8));
shade.style.background = `linear-gradient(90deg,rgba(0,0,0,${copyIn * 0.3}),transparent 78%),linear-gradient(transparent 55%,rgba(0,0,0,${lerp(0.15, 0.32, copyIn)}))`;
hero.classList.toggle("is-immersive", progress > IMMERSIVE_AT);
if (progress !== target) requestRender();
}
/* Unfold / fold ----------------------------------------------------------- */
const behavior = () => (reducedQuery.matches ? "instant" : "smooth");
function unfold() {
cancelIntro();
window.scrollTo({ top: maxScroll(), behavior: behavior() });
}
main.addEventListener("click", (e) => {
e.stopPropagation();
if (swiped) return;
if (progress < IMMERSIVE_AT) unfold();
});
window.addEventListener("scroll", () => {
target = clamp(scrollY / maxScroll());
if (scrollY > 30) cancelIntro();
scheduleIntro();
requestRender();
}, { passive: true });
window.addEventListener("resize", measure);
/* Study switch with a clip-path wipe -------------------------------------- */
async function selectStudy(index) {
if (index === active) return;
const token = ++sequence;
const next = STUDIES[index];
const preload = new Image();
preload.src = next.src;
try { await preload.decode(); } catch { return; }
if (token !== sequence) return;
// A copy of the old photo sits on top and is wiped away upwards.
$$(".cs-transition-image").forEach((el) => el.remove());
const old = photo.cloneNode();
old.removeAttribute("id");
old.alt = "";
old.className = "cs-transition-image";
old.setAttribute("aria-hidden", "true");
if (!reducedQuery.matches) main.insertBefore(old, shade);
active = index;
photo.src = preload.src;
photo.alt = next.alt;
photo.style.objectPosition = next.position;
paintCards();
$("#scene-label").textContent = `Study 0${index + 1} — ${next.name}`;
$("#scene-title").innerHTML = next.title;
$("#scene-description").textContent = next.line;
if (!reducedQuery.matches) {
const wipe = old.animate([{ clipPath: "inset(0 0 0 0)" }, { clipPath: "inset(0 0 100% 0)" }], WIPE);
wipe.finished.then(() => old.remove()).catch(() => old.remove());
}
requestRender();
}
// The side frames show the two studies that are not active (near-left and far-right
// = the next one, near-right and far-left = the one after), fading their photo in
// when it changes.
function paintCards() {
cards.forEach((card, i) => {
const index = (active + (i === 1 || i === 3 ? 1 : 2)) % 3;
if (Number(card.dataset.study) === index) return;
const study = STUDIES[index];
const img = card.querySelector("img");
card.dataset.study = index;
card.setAttribute("aria-label", `Show study 0${index + 1}, ${study.name}`);
img.src = study.thumb;
img.style.objectPosition = study.position;
if (!reducedQuery.matches) img.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 700, easing: "ease-out" });
});
}
cards.forEach((card) => card.addEventListener("click", (e) => {
e.stopPropagation();
selectStudy(Number(card.dataset.study));
}));
// Horizontal swipe on the centre frame: left = next, right = previous.
let down = null, swiped = false;
main.addEventListener("pointerdown", (e) => { down = { x: e.clientX, y: e.clientY }; swiped = false; });
main.addEventListener("pointerup", (e) => {
if (!down) return;
const dx = e.clientX - down.x, dy = e.clientY - down.y;
down = null;
if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.5) {
swiped = true;
selectStudy((active + (dx < 0 ? 1 : 2)) % 3);
}
});
main.addEventListener("pointercancel", () => { down = null; });
document.addEventListener("keydown", (e) => {
if (e.altKey || e.metaKey || e.ctrlKey) return;
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
selectStudy((active + (e.key === "ArrowRight" ? 1 : 2)) % 3);
}
if (e.key === "Escape" && progress > IMMERSIVE_AT) window.scrollTo({ top: 0, behavior: behavior() });
});
/* Intro: clip-path aperture, converging frames ---------------------------- */
function cancelIntro() {
clearTimeout(introTimer);
introAnimations.forEach((a) => a.cancel());
introAnimations = [];
introRunning = false;
}
// Replay after a hold, only while the sheet is resting at the top.
function scheduleIntro() {
clearTimeout(introTimer);
if (!LOOP || reducedQuery.matches || introRunning || scrollY > 30) return;
introTimer = setTimeout(() => {
if (scrollY <= 30 && progress < 0.01) enter();
}, HOLD_MS);
}
function enter() {
cancelIntro(); // rapid replays never stack
if (reducedQuery.matches || scrollY > 30) return;
introRunning = true;
introAnimations.push(main.animate(
[{ opacity: 0, clipPath: "inset(50% 0 50% 0)" }, { opacity: 1, clipPath: "inset(0 0 0 0)" }],
{ duration: 1500, easing: INTRO_EASE }
));
cards.forEach((card, i) => {
const rect = card.getBoundingClientRect();
const dx = innerWidth / 2 - (rect.x + rect.width / 2); // start at the screen centre
const direction = i < 2 ? 1 : -1;
introAnimations.push(card.animate(
[{ opacity: 0, transform: `translateX(${dx}px) scale(.8) rotateY(${direction * 24}deg)` }, { opacity: 1, transform: "translateX(0) scale(1) rotateY(0)" }],
{ duration: 1900, delay: 170 + Math.abs(i - 1.5) * 90, easing: INTRO_EASE, fill: "backwards" }
));
});
Promise.all(introAnimations.map((a) => a.finished)).then(() => {
introAnimations = [];
introRunning = false;
scheduleIntro();
}).catch(() => {});
}
// A click on the empty stage replays the intro straight away.
hero.addEventListener("click", () => {
if (progress < 0.01 && !reducedQuery.matches) enter();
});
// Replay from anywhere (e.g. wire it to your own "Play again" button): jump back to
// the top, snap the sheet closed (no lerp back down), then replay the intro.
function replay() {
cancelIntro();
window.scrollTo({ top: 0, behavior: "instant" });
cancelAnimationFrame(frame);
target = progress = 0;
render();
enter();
}
window.replayContactSheet = replay;
/* Start ------------------------------------------------------------------- */
measure();
(async () => {
await Promise.race([photo.decode().catch(() => {}), new Promise((r) => setTimeout(r, 1800))]);
enter();
})();
})();
</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>
원본 구현 프롬프트
# Contact Sheet: a row of photo frames unfolds into one full-screen photograph as you scroll
**Goal:** build a sticky scroll hero where a contact sheet of five photo frames unfolds on scroll: the centre frame grows smoothly from its slot to fill the screen while the four side frames fly outwards in 3D and fade, then a block of copy rises in over the full-screen photo. Side frames swap the photo with an upward clip-path wipe, and the row opens with an aperture intro.
## Visual behaviour
1. **Layout:** a tall scroll track (`230svh`) containing a sticky hero (`position: sticky; top: 0; height: 100svh`, `overflow: hidden`). Inside it, five frames are absolutely positioned in a horizontal row, vertically centred, inside a layer with `perspective: 1800px`: a small far frame and a taller near frame on each side, and a large 3:2-ish centre frame. All have 6px rounded corners and a light grey placeholder background. Nothing else is on screen at the start: no headline, no captions.
2. **Intro (on load, once the centre photo has decoded, max 1.8 s wait):**
- The centre frame opens through a horizontal aperture: `clip-path: inset(50% 0 50% 0)` with `opacity: 0` → `clip-path: inset(0 0 0 0)` with `opacity: 1`, over **1500 ms**, `cubic-bezier(.2,.7,.2,1)`.
- Each side frame converges out of the screen centre: from `opacity: 0; translateX(<distance from its centre to the screen centre>) scale(.8) rotateY(±24deg)` to its resting place, over **1900 ms**, same easing, `fill: backwards`. Left frames rotate +24°, right frames −24°. Delay = **170 ms + |i − 1.5| × 90 ms**, where i = 0…3 for far-left, near-left, near-right, far-right, so the near frames arrive first and the far frames slightly later.
- **Loop:** while the page sits at the top (`scrollY ≤ 30` and progress < 0.01), the intro replays after a **2.5 s** hold. A click on the empty stage replays it immediately. Scrolling past 30px cancels any running intro. Restarting always cancels the previous run first, so rapid clicks never stack.
3. **Scroll progress:** `target = scrollY / (trackHeight − heroHeight)`, clamped 0…1. Every animation frame, `progress += (target − progress) × 0.13`; snap to target when the difference is below 0.0002. Only run the loop while progress is catching up.
4. **Centre frame grows (driven by progress):** `p = smoothstep(clamp(progress / 0.9))` with `smoothstep(t) = t² (3 − 2t)`. Interpolate the frame's `left`, `top`, `width` and `height` from its resting box (read from the CSS on load and on resize) to `0, 0, heroWidth, heroHeight`. The corner radius goes from 6px to 0 (`6 × (1 − p)`). It is full screen at 90% of the scroll. The photo inside is the same `<img>` throughout (`object-fit: cover`), never swapped for a different element.
5. **Side frames fly out (same p):** each frame gets
`translate3d(dir × p × heroWidth × 0.45, p × (±28px), −p × 200px) rotateY(dir × p × 35deg)`, with `dir = −1` for the left frames and `+1` for the right ones, and the Y offset alternating −28px / +28px per frame (far-left up, near-left down, near-right up, far-right down). Opacity = `1 − clamp(p × 1.6)`, so they are gone by p = 0.625. Make them non-interactive (`inert`) once p > 0.3.
6. **Copy rises in:** `copyIn = smoothstep(clamp((progress − 0.66) / 0.28))`. A block in the lower-left (bottom 20%, left 7%, white text: an uppercase eyebrow, a large two-line headline, one line of body copy) goes from `opacity: 0; translateY(35px)` to `opacity: 1; translateY(0)`.
7. **Shade:** an overlay on the centre frame darkens it for legibility. Its background is two gradients driven by copyIn: `linear-gradient(90deg, rgba(0,0,0, copyIn × 0.3), transparent 78%)` over `linear-gradient(transparent 55%, rgba(0,0,0, lerp(0.15, 0.32, copyIn)))`, at 0.8 opacity.
8. **Click to unfold:** clicking the centre frame (when progress < 0.66) smooth-scrolls to the end of the track. **Escape** (when progress > 0.66) smooth-scrolls back to the top. Past 0.66 the centre frame drops its pointer cursor.
9. **Switching photos:** there are three photos ("studies"). The side frames always show the two that aren't active: near-left and far-right show the next one, near-right and far-left the one after. Clicking a side frame, pressing ← / →, or swiping horizontally on the centre frame (|dx| > 45px and |dx| > 1.5 × |dy|; left = next, right = previous) selects a study:
- Preload and decode the new full-size photo first (ignore the result if a newer switch started meanwhile).
- Put a copy of the old photo on top of the centre frame (under the shade), swap the real image's source, then wipe the copy away upwards: `clip-path: inset(0 0 0 0)` → `inset(0 0 100% 0)` over **1100 ms**, `cubic-bezier(.65,0,.25,1)`, and remove it.
- Update the copy block (eyebrow "Study 0N — Name", headline, line) and each photo's `object-position` (focal point).
- Side frames that change photo fade it in: opacity 0 → 1 over **700 ms**, `ease-out`.
10. **Hover:** a side frame's photo scales to 1.06 over 1.6 s, `cubic-bezier(.22,.75,.2,1)`. Side photos sit at `saturate(0.8)`.
## Exact values
| What | Value |
|---|---|
| Scroll track | `230svh` (hero `100svh`, min-height 650px) → 130svh of scroll |
| Progress smoothing | lerp 0.13 per frame, snap below 0.0002 |
| Grow | full screen at 90% progress, smoothstep; radius 6px → 0 |
| Side frames out | X ±45% of hero width · Y ±28px alternating · Z −200px · rotateY ±35° · opacity `1 − p × 1.6` · inert past p 0.3 |
| Copy in | from 66% over 28% of progress, smoothstep · rise 35px |
| Immersive state | progress > 0.66 |
| Perspective | 1800px on the frames' layer |
| Intro, centre | clip-path `inset(50% 0 50% 0)` → `inset(0)` + opacity 0 → 1 · 1500 ms · `cubic-bezier(.2,.7,.2,1)` |
| Intro, sides | from screen centre, scale .8, rotateY ±24°, opacity 0 · 1900 ms · same easing · delay 170 ms + \|i − 1.5\| × 90 ms |
| Intro loop | 2500 ms hold, only while scrollY ≤ 30 |
| Photo wipe | clip-path `inset(0)` → `inset(0 0 100% 0)` · 1100 ms · `cubic-bezier(.65,0,.25,1)` |
| Side photo fade | 700 ms `ease-out` |
| Swipe threshold | 45px, horizontal dominance 1.5× |
| Frame boxes (desktop, % of hero) | far-left `left 1%, top 36.5%, 13% × 27%` · near-left `left 15.2%, top 31%, 17% × 38%` · centre `left 33.4%, top 27%, 33.2% × 46%` · near-right `left 67.8%, top 31%, 17% × 38%` · far-right `left 86%, top 36.5%, 13% × 27%` |
| Copy block | bottom 20%, left 7% · eyebrow 10px, weight 500, uppercase, 0.25em tracking, 80% white · headline `clamp(48px, 6vw, 108px)`, line-height 1, −0.04em tracking, 25px margins, emphasised words at 70% white · body 18px |
## Markup
```html
<main class="track"> <!-- height: 230svh -->
<section class="hero"> <!-- sticky, 100svh, overflow hidden -->
<div class="sheet"> <!-- absolute inset 0, perspective 1800px -->
<button class="card card-far card-0" data-study="2"><img src="…" alt=""></button>
<button class="card card-near card-1" data-study="1"><img src="…" alt=""></button>
<div class="main"> <!-- left/top/width/height animated -->
<img class="main-photo" src="…" alt="…">
<div class="shade"></div>
</div>
<button class="card card-near card-3" data-study="2"><img src="…" alt=""></button>
<button class="card card-far card-4" data-study="1"><img src="…" alt=""></button>
</div>
<div class="copy" aria-hidden="true">
<p class="eyebrow">Study 01 — Dune</p>
<h2>Sand, wind<br>and a <em>long coat.</em></h2>
<p>A figure on the ridge, held by the light.</p>
</div>
</section>
</main>
```
- Frames are `position: absolute; overflow: hidden`, with `will-change: transform, opacity` and `backface-visibility: hidden`. Images are `object-fit: cover` and not draggable.
- Animate the centre frame's box (left/top/width/height), not a scale transform, so the photo stays sharp and its crop re-fits as the frame changes shape.
- Store the studies as data (large photo, small photo, focal point, name, headline, line, alt). Use a large file (around 2400px wide) for the centre photo, since it ends up full screen.
- Re-read the centre frame's resting box on resize.
## Libraries
None needed: a `requestAnimationFrame` loop for the scroll-driven lerp, and the Web Animations API (`element.animate`) for the intro, wipe and fades, with a CSS transition for the hover. If you prefer GSAP, the scroll part maps to a ScrollTrigger on the track with `scrub` (a scrub of roughly 0.5–1 s approximates the 0.13 lerp) and the intro and wipe to `gsap.fromTo` tweens with the same easings.
## Responsive and reduced motion
- **Below 760px:** hide the far frames. The centre frame becomes `left 22%, top 29%, 56% × 42%`; the near frames bleed off the edges at `left −10%` and `left 81%`, `top 34.5%, 29% × 31%`. The hero's min-height is 590px. The copy block spans `left 7%` to `right 7%` at bottom 22%, with a 44px headline, 15px body copy (max 240px wide) and an 8px eyebrow at 0.15em tracking. The centre frame uses `touch-action: pan-y` so vertical scrolling still works while it listens for horizontal swipes.
- **Short desktop screens** (height ≤ 690px, width ≥ 761px): hero min-height 550px; centre frame `top 28.5%`, height 43%; near frames `top 32.5%`, height 35%.
- **`prefers-reduced-motion: reduce`:** no intro and no intro loop (everything is visible at rest); progress follows the scroll directly, without the lerp; photo switches are instant, with no wipe and no fade; click-to-unfold and Escape jump instead of smooth-scrolling.
## Acceptance criteria
- On load, the centre frame opens from a horizontal slit while the four side frames sweep out of the centre and turn flat, the near pair slightly ahead of the far pair; at the top of the page this replays every 2.5 s after it finishes, and a click on the empty stage replays it at once without stacking.
- Scrolling grows the centre frame smoothly (with a slight lag behind the scroll) until it fills the screen exactly at 90% of the track, its corners easing to square, while the side frames swing outwards, backwards and away, fading out well before the end.
- The copy rises in over the full-screen photo in the last third of the scroll, with the photo darkening behind it, and everything reverses when scrolling back up.
- Clicking the centre frame scrolls to the full-screen state; Escape scrolls back.
- Clicking a side frame, using the arrow keys or swiping swaps the photo with an upward wipe; the side frames then show the two other photos, so every photo stays reachable.
- The centre photo stays sharp at full screen, and the subject stays in frame on portrait screens.
- With reduced motion, there's no intro or easing lag, and photo swaps are instant.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: f608ff69cecc09108464a8bfcc709f0fbe8a927522897634ec808d71ae2d4c23