모션·인터랙션 / Cadence
Panel Wipes
스크롤 · 갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Panel Wipes
A pinned, scroll-scrubbed project carousel. Every project after the first is a
full-screen colour panel carrying its photo; during its transition the panel is revealed
from the bottom edge upward (clip-path: inset(var(--reveal) 0 0 0)), while the left and
right title columns push up one screen height, so the next title rises with the wipe.
When scrolling stops, Lenis softly snaps to the nearest project.
Dependencies: GSAP 3.15 + ScrollTrigger, Lenis 1.3 (smooth scroll), from jsDelivr.
Tweak (CONFIG in the script):
- vhPerUnit: 180 → scroll height per project transition, in vh
- panelColors: alternating panel backgrounds (#FFFFFF / #EFEFEF)
- snapDelay: 140ms of stillness before snapping; snapDuration: 0.7s, ease-out cubic
- lenis: lerp 0.1 (smoothness)
- narrowBelow: 768px → titles move above and below the photo
- PROJECTS: titles, sub-lines and images
- .pw-stage --img-w: photo width (3:4 portrait)
From the Studio Monet site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel Wipes</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;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.pw {
position: relative;
width: 100%;
/* height is set by the script: (projects − 1) × vhPerUnit */
}
.pw-stage {
--img-w: min(49.5vh, 40vw); /* 3:4 photo, about 66vh tall */
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background: #fff;
}
@media (max-width: 767px) {
.pw-stage { --img-w: min(72vw, 42vh); }
}
/* Project 1's photo sits on the stage itself, the others inside their panels. */
.pw-base {
position: absolute;
inset: 0;
z-index: 2;
}
.pw-img {
position: absolute;
left: 50%;
top: 50%;
width: var(--img-w);
max-width: none;
aspect-ratio: 3 / 4;
height: auto;
object-fit: cover;
border-radius: 6px;
transform: translate(-50%, -50%);
user-select: none;
-webkit-user-drag: none;
}
/* Panels: full-stage colour blocks carrying the next photo, revealed bottom → top. */
.pw-panels {
position: absolute;
inset: 0;
z-index: 3;
}
.pw-panel {
position: absolute;
inset: 0;
/* --reveal goes 100% (hidden) → 0% (fully shown) */
clip-path: inset(var(--reveal, 100%) 0 0 0);
will-change: clip-path;
}
/* Titles: a left and a right column. Each side is a zero-size anchor at the stage
centre, offset to the margins (--sx / --sy, set by the script); the column inside
is moved vertically by GSAP. */
.pw-title {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
.pw-side {
position: absolute;
left: 50%;
top: 50%;
transform: translate(var(--sx, 0px), var(--sy, 0px));
}
.pw-col {
position: absolute;
left: 0;
top: 0;
will-change: transform;
}
/* One cell per project, stacked one slot (the stage height) apart. */
.pw-cell {
position: absolute;
left: 0;
top: 0;
text-align: center;
transform: translate(-50%, -50%) translateY(calc(var(--i) * var(--slot, 100vh)));
}
.pw-word {
font-size: clamp(1rem, 1.5vw, 1.55rem);
font-weight: 500;
line-height: 0.95;
letter-spacing: 0.22em;
text-transform: uppercase;
white-space: nowrap;
}
.pw-sub {
position: absolute;
top: 100%;
left: 50%;
margin-top: 0.7em;
font-size: clamp(0.72rem, 0.95vw, 0.95rem);
letter-spacing: 0.08em;
white-space: nowrap;
transform: translateX(-50%);
}
</style>
</head>
<body>
<!-- One tall section; its stage is pinned and scrubbed. The script builds the images,
panels and title columns from PROJECTS. -->
<main class="pw" data-experience>
<div class="pw-stage" data-stage>
<div class="pw-base" data-base></div>
<div class="pw-panels" data-panels></div>
<div class="pw-title" data-title></div>
</div>
</main>
<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 CONFIG = {
vhPerUnit: 180, // scroll height per timeline unit (vh)
projStep: 1.0, // timeline units per project transition
panelColors: ["#FFFFFF", "#EFEFEF"], // alternates per project
snapDelay: 140, // ms of scroll stillness before snapping
snapDuration: 0.7, // s, soft settle onto a project
lenis: { lerp: 0.1, smoothWheel: true, wheelMultiplier: 1 },
narrowBelow: 768, // below this width the titles stack above/below the photo
};
// Swap the picsum.photos placeholders for your own 3:4 portrait images.
const PROJECTS = [
{ left: "Project", right: "Camel Coat", date: "12/01/2026", discipline: "Art Direction", image: "https://picsum.photos/seed/cadence-1/1200/1600", alt: "" },
{ left: "Lavender", right: "Rows", date: "04/03/2026", discipline: "Photography", image: "https://picsum.photos/seed/cadence-2/1200/1600", alt: "" },
{ left: "The", right: "Linen Cape", date: "21/04/2026", discipline: "Editorial", image: "https://picsum.photos/seed/cadence-3/1200/1600", alt: "" },
{ left: "Pier", right: "In Winter", date: "09/06/2026", discipline: "Campaign", image: "https://picsum.photos/seed/cadence-4/1200/1600", alt: "" },
{ left: "Spiral", right: "Stair", date: "18/08/2026", discipline: "Creative Direction", image: "https://picsum.photos/seed/cadence-5/1200/1600", alt: "" },
];
const N = PROJECTS.length;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Build the DOM from PROJECTS ------------------------------------------- */
const experienceEl = document.querySelector("[data-experience]");
const stage = document.querySelector("[data-stage]");
const baseEl = document.querySelector("[data-base]");
const panelsEl = document.querySelector("[data-panels]");
const titleEl = document.querySelector("[data-title]");
const panelColor = (i) => CONFIG.panelColors[i % CONFIG.panelColors.length];
function makeImg(p) {
const img = document.createElement("img");
img.className = "pw-img";
img.src = p.image;
img.alt = p.alt;
img.width = 1200;
img.height = 1600;
img.decoding = "async";
return img;
}
// Project 1 sits on the stage itself.
const firstImg = makeImg(PROJECTS[0]);
baseEl.appendChild(firstImg);
// One panel per project from index 1: a full-stage colour block with the photo.
const panelEls = PROJECTS.slice(1).map((p, i) => {
const idx = i + 1;
const panel = document.createElement("div");
panel.className = "pw-panel";
panel.style.background = panelColor(idx);
panel.style.zIndex = String(idx); // later panels reveal over earlier ones
panel.appendChild(makeImg(p));
panelsEl.appendChild(panel);
return panel;
});
// Title carousel: a left and a right column, one cell per project.
const cells = (side) =>
PROJECTS.map(
(p, i) => `
<div class="pw-cell" style="--i:${i}">
<div class="pw-word">${side === "left" ? p.left : p.right}</div>
<div class="pw-sub">${side === "left" ? p.date : p.discipline}</div>
</div>`
).join("");
titleEl.innerHTML = `
<div class="pw-side pw-side--left"><div class="pw-col">${cells("left")}</div></div>
<div class="pw-side pw-side--right"><div class="pw-col">${cells("right")}</div></div>
`;
const sideL = titleEl.querySelector(".pw-side--left");
const sideR = titleEl.querySelector(".pw-side--right");
const cols = [...titleEl.querySelectorAll(".pw-col")];
/* Smooth scroll (Lenis) wired into GSAP's ticker + ScrollTrigger -------- */
let lenis = null;
if (!reduceMotion) {
lenis = new Lenis(CONFIG.lenis);
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
}
/* Layout ---------------------------------------------------------------- */
const cw = () => document.documentElement.clientWidth;
const ch = () => document.documentElement.clientHeight;
let layout = {};
function computeLayout() {
const slot = stage.offsetHeight || ch();
document.documentElement.style.setProperty("--slot", `${slot}px`);
const w = firstImg.offsetWidth;
const h = firstImg.offsetHeight;
if (cw() < CONFIG.narrowBelow) {
// Narrow screens: centred in the bands above and below the photo.
const y = (h / 2 + slot / 2) / 2;
sideL.style.cssText = `--sx: 0px; --sy: ${-y}px`;
sideR.style.cssText = `--sx: 0px; --sy: ${y}px`;
} else {
// Centred in the margin between the photo edge and the viewport edge.
const x = (w / 2 + cw() / 2) / 2;
sideL.style.cssText = `--sx: ${-x}px; --sy: 0px`;
sideR.style.cssText = `--sx: ${x}px; --sy: 0px`;
}
layout = { slot };
}
/* Project scroll (scrubbed): carousel + panel wipes --------------------- */
let st = null;
let projectTL = null;
let projectProgresses = [];
function buildProjectScroll() {
const { projStep } = CONFIG;
const projDur = (N - 1) * projStep;
const slot = layout.slot;
experienceEl.style.height = `${projDur * CONFIG.vhPerUnit}vh`;
gsap.set(cols, { y: 0 });
panelEls.forEach((p) => p.style.setProperty("--reveal", "100%"));
projectTL = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: experienceEl,
start: "top top",
end: "bottom bottom",
scrub: true,
pin: stage,
pinSpacing: false,
invalidateOnRefresh: true,
},
});
st = projectTL.scrollTrigger;
// Left and right title columns: one continuous vertical push.
projectTL.to(cols, { y: -(N - 1) * slot, ease: "none", duration: projDur }, 0);
// Panel wipes: reveal each panel bottom → top during its transition.
panelEls.forEach((panel, i) => {
const proxy = { v: 100 };
projectTL.to(
proxy,
{
v: 0,
ease: "none",
duration: projStep,
onUpdate: () => panel.style.setProperty("--reveal", `${proxy.v}%`),
},
i * projStep
);
});
// Snap targets: evenly spaced, one per project.
projectProgresses = PROJECTS.map((_, k) => k / (N - 1));
ScrollTrigger.refresh();
}
/* Soft snap: settles onto the nearest project once scrolling stops ------ */
let snapTimer;
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
function trySnap() {
if (!st || !lenis) return;
const range = st.end - st.start;
if (range <= 0) return;
const p = (lenis.scroll - st.start) / range;
let nearest = projectProgresses[0];
for (const pp of projectProgresses) {
if (Math.abs(pp - p) < Math.abs(nearest - p)) nearest = pp;
}
const target = st.start + nearest * range;
if (Math.abs(target - lenis.scroll) < 1.5) return; // already aligned
lenis.scrollTo(target, { duration: CONFIG.snapDuration, easing: easeOutCubic });
}
// Lenis emits a scroll event every frame (its own snap tween and momentum included),
// so this debounce waits for everything to settle; scrolling mid-snap re-debounces.
if (lenis) {
lenis.on("scroll", () => {
clearTimeout(snapTimer);
snapTimer = setTimeout(trySnap, CONFIG.snapDelay);
});
}
/* Boot ------------------------------------------------------------------ */
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
window.scrollTo(0, 0);
if (lenis) lenis.scrollTo(0, { immediate: true });
computeLayout();
buildProjectScroll();
// Re-measure once the first photo has its real size.
firstImg.addEventListener("load", () => computeLayout(), { once: true });
/* Responsive rebuild on real width changes (ignores the mobile address-bar
height jitter) and large height changes. */
let lastW = window.innerWidth;
let lastH = window.innerHeight;
let resizeTimer;
window.addEventListener("resize", () => {
const dh = Math.abs(window.innerHeight - lastH);
if (window.innerWidth === lastW && dh < 150) return;
lastW = window.innerWidth;
lastH = window.innerHeight;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(rebuild, 200);
});
function rebuild() {
const progress = st ? st.progress : 0;
if (projectTL) {
st.kill(true);
projectTL.kill();
}
computeLayout();
buildProjectScroll();
// Keep the visitor on the same project after the rebuild.
const target = st.start + progress * (st.end - st.start);
window.scrollTo(0, target);
if (lenis) lenis.scrollTo(target, { immediate: 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>
원본 구현 프롬프트
# Panel Wipes: a pinned project carousel where each project wipes up from the bottom
**Goal:** build a full-screen, scroll-scrubbed project showcase. The screen stays pinned while you scroll; each next project is revealed by a full-screen colour panel wiping up from the bottom edge, while the project titles in the side margins push up like a vertical carousel. When scrolling stops, the page softly snaps to the nearest project.
## Visual behaviour
1. **Layout of one screen (the stage).** A 100vh stage, white background. In the centre, a portrait photo (3:4, rounded 6px corners, about 66vh tall). A two-part title flanks it: the first word(s) centred in the left margin, the rest centred in the right margin (e.g. "PROJECT" | "CAMEL COAT"). Under each part, a small sub-line (left: a date, right: a discipline).
2. **Pinning.** The stage pins at the top of the viewport for the whole sequence. The scroll length is `(projects − 1) × 180vh` (for 5 projects: 720vh of section height). The scrub is linear (no easing) and directly tied to scroll position.
3. **Panel wipe.** Project 1 lives on the stage itself. Every following project is a full-stage panel (its own background colour, its photo in exactly the same centred position), stacked above the previous ones. During its transition, a panel is revealed from the bottom edge upward using `clip-path: inset(R 0 0 0)`, with `R` going linearly from `100%` (hidden) to `0%` (fully shown). The new photo and background slide in over the old ones, with a hard horizontal edge moving up the screen. Panel backgrounds alternate `#FFFFFF` and `#EFEFEF`, so the edge is visible.
4. **Title push.** Each side (left and right) has a column holding every project's title cell, stacked exactly one stage height apart. Over the whole sequence both columns move up linearly by `(projects − 1) × stageHeight`. So during each transition, the current title slides up and out of view while the next one rises into the centre, in sync with the wipe.
5. Transition `k` (from project `k` to `k + 1`) occupies the k-th equal slice of the pinned scroll range (each 180vh of scroll). Scrolling back reverses everything exactly.
6. **Soft snap.** Uses smooth scrolling (Lenis, lerp 0.1). After 140ms without any scroll movement (including smooth-scroll momentum), compute the progress through the pinned range, find the nearest project (evenly spaced at `k / (projects − 1)`), and smooth-scroll to it in 0.7s with ease-out cubic (`1 − (1 − t)³`). Skip if already within 1.5px. Any new scroll during the snap restarts the debounce.
## Exact values
| What | Value |
|---|---|
| Scroll per transition | 180vh |
| Scrub easing | none (linear) |
| Wipe | `clip-path: inset(100% 0 0 0)` → `inset(0% 0 0 0)` |
| Title column travel | one stage height per project |
| Panel colours | `#FFFFFF`, `#EFEFEF`, alternating |
| Photo | 3:4, width `min(49.5vh, 40vw)` (`min(72vw, 42vh)` below 768px), centred, radius 6px, `object-fit: cover` |
| Title | uppercase, weight 500, `clamp(1rem, 1.5vw, 1.55rem)`, letter spacing 0.22em, line height 0.95 |
| Sub-line | `clamp(0.72rem, 0.95vw, 0.95rem)`, letter spacing 0.08em, 0.7em below the title |
| Smooth scroll | Lenis `lerp: 0.1`, `smoothWheel: true` |
| Snap | 140ms debounce, 0.7s, ease-out cubic |
## Markup and structure
```html
<main class="pw"> <!-- height set by JS: (projects − 1) × 180vh -->
<div class="pw-stage"> <!-- 100vh, overflow hidden, pinned -->
<div class="pw-base"><img class="pw-img" …></div> <!-- project 1 -->
<div class="pw-panels">
<div class="pw-panel" style="background:#EFEFEF; z-index:1"><img class="pw-img" …></div>
<div class="pw-panel" style="background:#FFFFFF; z-index:2"><img class="pw-img" …></div>
…
</div>
<div class="pw-title">
<div class="pw-side pw-side--left"><div class="pw-col"> <!-- one .pw-cell per project --></div></div>
<div class="pw-side pw-side--right"><div class="pw-col"> … </div></div>
</div>
</div>
</main>
```
- Build the panels and title cells from a data array (left word, right word, date, discipline, image).
- Each `.pw-side` is a zero-size anchor at the stage centre, offset horizontally to the middle of the margin: `x = (photoWidth / 2 + viewportWidth / 2) / 2`, so `translate(−x, 0)` for left and `translate(x, 0)` for right.
- Cell `i` is placed at `translate(−50%, −50%) translateY(i × stageHeight)` inside its column. Store the stage height in a CSS variable and recompute it on resize.
- The title layer sits above the panels (z-index 20) and ignores pointer events.
## Libraries
- **GSAP 3 + ScrollTrigger:** one timeline with `scrollTrigger: { trigger: section, start: "top top", end: "bottom bottom", scrub: true, pin: stage, pinSpacing: false, invalidateOnRefresh: true }` and `defaults: { ease: "none" }`. Add one tween moving both columns to `y: −(N − 1) × stageHeight` over `N − 1` units at position 0, plus one tween per panel (duration 1, at position `i`) that animates a proxy value 100 → 0 and writes it to the panel's `--reveal` custom property (`clip-path: inset(var(--reveal) 0 0 0)`).
- **Lenis** for smooth scroll and the snap: call `ScrollTrigger.update` on Lenis's scroll event, drive `lenis.raf` from `gsap.ticker`, `gsap.ticker.lagSmoothing(0)`, and snap with `lenis.scrollTo(target, { duration: 0.7, easing })`.
- **Plain alternative:** a `position: sticky` stage inside a tall section, with a scroll listener computing progress `p` (0 → N−1) and setting each panel's `clip-path` (`inset(clamp(0, 1 − (p − i), 1) × 100% 0 0 0)` for panel `i`, counting from 0) and the columns' `translateY(−p × stageHeight)`. Use CSS `scroll-snap` or a debounced `scrollTo({ behavior: "smooth" })` for the snap.
## Responsive and reduced motion
- Below 768px: the photo becomes `min(72vw, 42vh)` wide, and the two title parts move from the side margins to above and below the photo, centred in those bands: `y = (photoHeight / 2 + stageHeight / 2) / 2`, so `translate(0, −y)` (top) and `translate(0, y)` (bottom).
- Rebuild the timeline on real width changes or height changes over 150px (ignore mobile address-bar jitter), keeping the visitor on the same progress.
- `prefers-reduced-motion: reduce`: no smooth scrolling and no snap. The wipes stay tied to native scroll.
## Acceptance criteria
- The stage stays pinned while the whole sequence plays; the page continues normally after it.
- Each 180vh of scroll reveals exactly one new project: its panel wipes up from the bottom with a straight horizontal edge, its photo landing exactly where the previous one was.
- The left and right titles slide up together, in sync with the wipe, and the new title is centred when the wipe completes.
- Scrolling back reverses the wipes and titles smoothly.
- Stopping mid-transition snaps softly (0.7s) to the nearest project.
- On phones, the titles sit above and below the photo and nothing overflows.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: eac7635b2f96fd47efa09d62f68d45cb31777d5857ccfbb395a6e639a6df4842