모션·인터랙션 / Cadence
Opposing Pager
스크롤 · 페이지 전환 · 갤러리 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Opposing Pager
A split project view. A 3:4 thumbnail sits in the right half next to the project name.
Opening it (click, scroll down, or on its own 1 s after load) reuses the very same image
node: it is pinned to its exact pixel box and its top / left / width / height grow to fill
the right half (corners square off), while a white block slides down over the left half
and the title and two text sections fade in one by one. Then the page becomes a pager:
two tracks of half-screen panels that push in OPPOSITE vertical directions (the left page
enters from the bottom, the right one from the top), one page per wheel / swipe / arrow
gesture. Stepping back past the first page (or Escape) fades the pager and contracts the
image to its thumbnail again.
Dependencies: none (plain CSS + JS).
Tweak:
- --ease: cubic-bezier(0.16, 1, 0.3, 1) for everything
- --hero-dur 950ms (image expand / contract), --reveal-dur 820ms (white block slide),
--pv-dur 1725ms (one page push), --fade-dur 720ms (each text block)
- SECTION_STAGGER 220ms between text blocks, PV_FADE_OUT 360ms before closing
- STEP_THRESHOLD 14 (wheel delta per step), TOUCH_THRESHOLD 28px, STEP_INTERVAL 55ms
- AUTO_OPEN_DELAY 1000ms (set to Infinity to wait for the visitor)
- PROJECT: the copy and the images for each page
Based on the project view of the Kendal site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Opposing Pager</title>
<style>
:root {
--gutter: clamp(16px, 2.5vw, 32px);
--max-width: 1440px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, p { margin: 0; }
img { display: block; max-width: 100%; }
/* The stage fills the viewport */
.op-stage { position: fixed; inset: 0; }
.op-stage {
/* Motion — gentle, luxurious ease-out (original) */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--hero-dur: 950ms; /* shared image expand to full height */
--reveal-dur: 820ms; /* white block slide-down over the left half */
--pv-dur: 1725ms; /* project-page push (slower, more deliberate) */
--fade-dur: 720ms; /* each text block fades in */
/* Text inset: aligned to a centred max-width column */
--inset-x: max(var(--gutter), calc((100vw - var(--max-width)) / 2 + var(--gutter)));
--inset-y: 2.4rem; /* original: the header's vertical padding, mirrored */
/* Layout rhythm (original) */
--pad-left: 5.4vw; /* left text inset of the project name */
--name-step: 18vh; /* the hairlines sit ±0.7 × this from the centre */
/* A 3:4 thumbnail, as large as a 44vw × 64vh box allows */
--thumb-w: min(44vw, 64vh * 3 / 4);
--thumb-h: calc(var(--thumb-w) * 4 / 3);
overflow: hidden;
overscroll-behavior: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
color: #000;
}
/* Split shell ---------------------------------------------------------------- */
.op-split {
display: flex;
width: 100%;
height: 100%;
}
.op-panel {
position: relative;
flex: 0 0 50%;
width: 50%;
height: 100%;
overflow: hidden;
}
/* LEFT — the project name (entry state) -------------------------------------- */
.op-name {
position: absolute;
top: 50%;
left: var(--pad-left);
white-space: nowrap;
transform: translateY(-50%);
}
.op-name__title {
display: block;
font-weight: 400;
line-height: 1.02;
font-size: clamp(2.6rem, 5vw, 4.6rem);
letter-spacing: -0.01em;
}
.op-name__tag,
.op-tag {
display: block;
margin-top: 1.1rem;
font-weight: 500;
font-size: 0.7rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #8e8e8e;
}
.op-hairline {
position: absolute;
left: var(--pad-left);
width: min(21rem, 100% - var(--pad-left) - var(--gutter));
height: 1px;
background: #e6e6e6;
}
.op-hairline--top {
top: calc(50% - var(--name-step) * 0.7);
}
.op-hairline--bottom {
top: calc(50% + var(--name-step) * 0.7);
}
/* RIGHT — the thumbnail (shared element) ------------------------------------- */
.op-thumb {
position: absolute;
top: 50%;
left: 50%;
width: var(--thumb-w);
height: var(--thumb-h);
transform: translate(-50%, -50%);
overflow: hidden;
padding: 0;
border: 0;
border-radius: 6px;
background: #efefef;
cursor: pointer;
/* The script animates top / left / width / height / radius while expanding */
will-change: top, left, width, height;
}
.op-thumb--slot {
visibility: hidden;
pointer-events: none;
}
.op-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
/* small smooth zoom on hover (original) */
transition: transform 600ms var(--ease);
}
.op-thumb:focus-visible {
outline: 1px solid #000;
outline-offset: 4px;
}
/* Hover affordance — a soft veil and a thin plus (original) */
.op-thumb__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 0.18);
opacity: 0;
pointer-events: none;
transition: opacity 450ms var(--ease);
}
.op-thumb__plus {
position: relative;
width: 40px;
height: 40px;
}
.op-thumb__plus::before,
.op-thumb__plus::after {
content: "";
position: absolute;
background: #fff;
}
.op-thumb__plus::before {
top: 0;
left: 50%;
width: 1.5px;
height: 100%;
transform: translateX(-50%);
}
.op-thumb__plus::after {
top: 50%;
left: 0;
width: 100%;
height: 1.5px;
transform: translateY(-50%);
}
@media (hover: hover) {
.op-thumb.is-current:hover img {
transform: scale(1.05);
}
.op-thumb.is-current:hover .op-thumb__overlay {
opacity: 1;
}
}
.op-thumb:not(.is-current) {
cursor: default;
}
/* PROJECT PAGE — the white block over the left half -------------------------- */
.op-page {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
z-index: 15;
background: #fff;
/* White block sits above the viewport, then slides down to reveal. */
transform: translateY(-100%);
transition: transform var(--reveal-dur) var(--ease);
pointer-events: none;
}
.op-stage.is-open .op-page {
transform: translateY(0);
}
.op-page__inner {
position: absolute;
inset: 0;
}
/* The project title, top left */
.op-title {
position: absolute;
top: clamp(96px, 15vh, 150px);
left: var(--inset-x);
right: var(--inset-x);
}
.op-title .op-tag {
margin: 0 0 1.1rem;
}
.op-title__text {
font-weight: 400;
line-height: 1.02;
font-size: clamp(2.6rem, 5vw, 4.6rem);
letter-spacing: -0.01em;
}
/* Seated in the bottom-left corner, mirroring the header inset (original) */
.op-sections {
position: absolute;
left: var(--inset-x);
right: var(--inset-x);
bottom: var(--inset-y);
}
.op-section {
width: 22rem;
max-width: 32vw;
padding-bottom: 1.7rem;
border-bottom: 1px solid #e6e6e6;
}
.op-section + .op-section {
margin-top: 2.5rem;
}
/* Very short screens keep one section, so the title never meets them */
@media (max-height: 600px) {
.op-section:nth-child(2) {
display: none;
}
}
/* Each block fades in gently once the white area has settled (original) */
.op-reveal {
opacity: 0;
transform: translateY(8px);
transition: opacity var(--fade-dur) var(--ease), transform var(--fade-dur) var(--ease);
}
.op-reveal.is-in {
opacity: 1;
transform: translateY(0);
}
.op-section__head {
margin-bottom: 1.1rem;
font-weight: 500;
font-size: 0.72rem;
line-height: 1.4;
letter-spacing: 0.25em; /* original: 0.26em */
text-transform: uppercase;
}
.op-section__body {
font-weight: 400;
font-size: 0.9rem;
line-height: 1.7;
color: rgb(0 0 0 / 0.72);
}
/* PROJECT VIEW — two tracks of half-panels that push in opposite directions -- */
.op-pv {
position: absolute;
inset: 0;
z-index: 16;
opacity: 0;
pointer-events: none;
transition: opacity 350ms var(--ease);
}
.op-pv.is-shown {
opacity: 1;
pointer-events: auto;
}
.op-track {
position: absolute;
top: 0;
width: 50%;
height: 100%;
overflow: hidden;
}
.op-track--left {
left: 0;
}
.op-track--right {
left: 50%;
}
.op-pv-panel {
position: absolute;
inset: 0;
overflow: hidden;
background: #fff;
transition: transform var(--pv-dur) var(--ease);
will-change: transform;
}
.op-pv-image img {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
}
/* A smaller image floating in white space */
.op-pv-figure {
display: flex;
align-items: center;
justify-content: center;
}
.op-pv-figure img {
width: auto;
height: 58%; /* of the half: 58vh on desktop (original) */
max-width: calc(100% - 2 * var(--gutter));
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 6px;
-webkit-user-drag: none;
}
/* Centred attribution + quote */
.op-pv-quote {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 7vw;
text-align: center;
}
.op-pv-quote__name {
margin-bottom: 1.9rem;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.25em; /* original: 0.28em */
text-transform: uppercase;
}
.op-pv-quote__text {
max-width: 24rem;
font-weight: 400;
font-size: clamp(1.25rem, 1.8vw, 1.7rem);
line-height: 1.5;
}
/* A closing credits page, styled like the sections */
.op-credits {
position: absolute;
left: var(--inset-x);
right: var(--inset-x);
bottom: var(--inset-y);
width: 22rem;
max-width: calc(100% - 2 * var(--inset-x));
margin: 0;
}
.op-credits__row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.05rem 0;
border-bottom: 1px solid #e6e6e6;
}
.op-credits dt {
font-weight: 500;
font-size: 0.72rem;
line-height: 1.9;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.op-credits dd {
margin: 0;
font-size: 0.9rem;
line-height: 1.6;
color: rgb(0 0 0 / 0.72);
text-align: right;
}
/* Stacked layout (phones and tall portrait screens) --------------------------
The halves stack top / bottom. The pager still
pushes the two tracks vertically in opposite directions, so new pages open from the seam. */
@media (max-width: 600px), (max-aspect-ratio: 4/5) {
.op-stage {
--pad-left: var(--inset-x);
--inset-y: 1.6rem;
--thumb-w: min(100vw - 2 * var(--gutter), 32vh * 3 / 4);
}
.op-split {
flex-direction: column;
}
.op-panel {
flex: 0 0 50%;
width: 100%;
height: 50%;
}
.op-name {
top: 54%;
}
.op-name__title,
.op-title__text {
font-size: clamp(2.2rem, 7vw, 4.6rem);
}
.op-hairline--top {
top: calc(54% - var(--name-step) * 0.62);
}
.op-hairline--bottom {
top: calc(54% + var(--name-step) * 0.62);
}
.op-page {
width: 100%;
height: 50%;
}
.op-title {
top: 84px;
}
.op-section {
width: auto;
max-width: 30rem;
padding-bottom: 1.2rem;
}
.op-section + .op-section {
margin-top: 1.6rem;
}
/* Only the first section fits the half height */
.op-section:not(:first-child) {
display: none;
}
.op-track {
width: 100%;
height: 50%;
}
.op-track--right {
left: 0;
top: 50%;
}
.op-pv-quote {
padding: 0 calc(var(--gutter) * 2);
}
.op-pv-quote__name {
margin-bottom: 1.2rem;
}
.op-pv-quote__text {
font-size: clamp(1.1rem, 4.6vw, 1.7rem);
}
.op-credits {
width: auto;
}
.op-credits__row {
padding: 0.6rem 0;
}
}
/* Short phones (stacked halves under 720px tall) tighten the one
section and the bottom inset, so it stays clear of the title */
@media (max-width: 600px) and (max-height: 720px) {
.op-sections {
bottom: 1.5rem;
}
.op-section {
padding-bottom: 0.9rem;
}
.op-section__head {
margin-bottom: 0.6rem;
}
.op-section__body {
font-size: 0.8rem;
line-height: 1.55;
}
}
/* …and the very shortest drop the small tag above the title */
@media (max-width: 600px) and (max-height: 600px) {
.op-title .op-tag {
display: none;
}
}
/* Tall portrait screens (tablets) have room for a second section */
@media (max-aspect-ratio: 4/5) and (min-height: 960px) and (min-width: 601px) {
.op-section:nth-child(2) {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.op-stage {
--hero-dur: 1ms;
--reveal-dur: 1ms;
--pv-dur: 1ms;
--fade-dur: 1ms;
}
.op-thumb img,
.op-pv {
transition-duration: 1ms;
}
}
</style>
</head>
<body>
<div class="op-stage" id="stage">
<div class="op-split">
<section class="op-panel op-panel--left" aria-label="Project">
<div class="op-name" id="name">
<span class="op-name__title"></span>
<span class="op-name__tag"></span>
</div>
<!-- Static hairlines that frame the project name -->
<span class="op-hairline op-hairline--top"></span>
<span class="op-hairline op-hairline--bottom"></span>
</section>
<section class="op-panel op-panel--right" id="rightPanel" aria-label="Project image">
<!-- Invisible copy of the thumbnail's resting box, measured for the return -->
<span class="op-thumb op-thumb--slot" id="thumbSlot" aria-hidden="true"></span>
<button class="op-thumb" id="thumb" type="button" aria-label="Open project">
<img alt="" draggable="false">
<span class="op-thumb__overlay"><span class="op-thumb__plus"></span></span>
</button>
</section>
</div>
<!-- Project page: a white block slides down over the left half; the right half is the
thumbnail itself, expanded to full height. -->
<div class="op-page" id="projectPage" aria-hidden="true">
<div class="op-page__inner" id="pageInner"></div>
</div>
<!-- Project view: the paging layer, built by the script. -->
<div class="op-pv" id="pv" aria-hidden="true"></div>
</div>
<script>
/* Constants (from the original) ------------------------------------------- */
const STEP_THRESHOLD = 14; // wheel delta needed to commit a step
const STEP_INTERVAL = 55; // min ms between chained steps
const TOUCH_THRESHOLD = 28; // px of touch travel per step
const SECTION_STAGGER = 220; // ms between text blocks fading in
const SECTION_FADE = 720; // ms, each block's fade (the entry ends after the last one)
const PV_FADE_OUT = 360; // ms, the paging layer fades before the image contracts
const AUTO_OPEN_DELAY = 1000; // ms on the entry state before it opens on its own
// Durations and the ease live in the CSS (--hero-dur 950ms, --reveal-dur 820ms,
// --pv-dur 1725ms, cubic-bezier(0.16, 1, 0.3, 1)); the script reads them from there.
/* Project ------------------------------------------------------------------- */
// Placeholder 3:4 photos: each `img` is a picsum seed. Swap pic() for your own image URLs
// (a small ~600px and a large ~1600px version of each).
const pic = (seed, w) => `https://picsum.photos/seed/${seed}/${w}/${Math.round((w * 4) / 3)}`;
const PROJECT = {
name: "Pier in Winter",
category: "Editorial",
hero: { img: "cadence-1", alt: "Hero image" },
sections: [
{
head: "The Concept",
body: "A study of one coat on one morning. We kept to the sea wall and let the fog do the rest, so the frame stays quiet and the figure has room to breathe.",
},
{
head: "Art Direction",
body: "Navy wool against whitewashed stone and a pale, flat sky. Styling stayed close to the essentials: a heavy coat, a cream knit, nothing else competing.",
},
],
pages: {
leftImages: [
{ img: "cadence-2", alt: "Project image 2" },
{ img: "cadence-3", alt: "Project image 3" },
],
figure: { img: "cadence-4", alt: "Project image 4" },
quote: {
name: "North Studio",
text: "We waited all morning for the fog to lift. In the end we were glad it never did.",
},
credits: [
["Photography", "North Studio"],
["Styling", "Atelier"],
["Location", "North Pier"],
["Season", "Winter"],
],
lastImage: { img: "cadence-5", alt: "Project image 5" },
},
};
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const imgTag = (p, cls = "") =>
`<img${cls ? ` class="${cls}"` : ""} src="${pic(p.img, 600)}" ` +
`srcset="${pic(p.img, 600)} 600w, ${pic(p.img, 1600)} 1600w" ` +
`sizes="(max-width: 600px) 100vw, (max-aspect-ratio: 4/5) 100vw, 50vw" ` +
`alt="${p.alt}" width="1600" height="2133" draggable="false">`;
/* StepScroll --------------------------------------------------------------
Turns continuous wheel / trackpad / touch input into discrete steps, but
allows rapid chaining. */
class StepScroll {
constructor({ target, onStep, threshold = STEP_THRESHOLD, interval = STEP_INTERVAL }) {
this.target = target;
this.onStep = onStep;
this.threshold = threshold;
this.interval = interval;
this.accum = 0;
this.last = 0;
this.touchY = null;
this.target.addEventListener("wheel", this._onWheel, { passive: false });
this.target.addEventListener("touchstart", this._onTouchStart, { passive: true });
this.target.addEventListener("touchmove", this._onTouchMove, { passive: false });
this.target.addEventListener("touchend", this._onTouchEnd, { passive: true });
window.addEventListener("keydown", this._onKey);
}
// Commit one step, throttled so a fast fling chains smoothly.
_commit(dir) {
const now = performance.now();
if (now - this.last < this.interval) return false;
this.last = now;
this.accum = 0;
this.onStep(dir);
return true;
}
_onWheel = (e) => {
e.preventDefault();
this.accum += e.deltaY;
if (this.accum >= this.threshold) this._commit(1);
else if (this.accum <= -this.threshold) this._commit(-1);
};
_onTouchStart = (e) => {
this.touchY = e.touches[0].clientY;
};
_onTouchMove = (e) => {
if (this.touchY === null) return;
e.preventDefault();
const dy = this.touchY - e.touches[0].clientY;
if (Math.abs(dy) > TOUCH_THRESHOLD) {
if (this._commit(dy > 0 ? 1 : -1)) this.touchY = e.touches[0].clientY;
}
};
_onTouchEnd = () => {
this.touchY = null;
};
_onKey = (e) => {
if (e.key === "ArrowDown" || e.key === "PageDown") this._commit(1);
else if (e.key === "ArrowUp" || e.key === "PageUp") this._commit(-1);
};
}
/* Elements ------------------------------------------------------------------ */
const stage = document.getElementById("stage");
const rightPanel = document.getElementById("rightPanel");
const thumbEl = document.getElementById("thumb");
const thumbImg = thumbEl.querySelector("img");
const slotEl = document.getElementById("thumbSlot");
const projectPage = document.getElementById("projectPage");
const pageInner = document.getElementById("pageInner");
const pvEl = document.getElementById("pv");
// Entry state
const nameEl = document.getElementById("name");
nameEl.querySelector(".op-name__title").textContent = PROJECT.name;
nameEl.querySelector(".op-name__tag").textContent = PROJECT.category;
thumbImg.outerHTML = imgTag(PROJECT.hero);
const heroImg = thumbEl.querySelector("img");
thumbEl.classList.add("is-current");
// Page 0 (left): title + two sections, each fading in on its own
pageInner.innerHTML =
`<div class="op-title op-reveal"><span class="op-tag">${PROJECT.category}</span>` +
`<h1 class="op-title__text">${PROJECT.name}</h1></div>` +
`<div class="op-sections">` +
PROJECT.sections
.map(
(s) =>
`<article class="op-section op-reveal"><h2 class="op-section__head">${s.head}</h2>` +
`<p class="op-section__body">${s.body}</p></article>`
)
.join("") +
`</div>`;
const revealEls = [...pageInner.querySelectorAll(".op-reveal")];
/* Paging layer ---------------------------------------------------------------
page 0 left: title + sections (white) right: hero image
page 1 left: full-bleed image right: small image in white
page 2 left: full-bleed image right: attribution + quote
page 3 left: credits (white) right: full-bleed image */
let pvLeft = [];
let pvRight = [];
function buildPV() {
const panel = (cls, html = "") => {
const el = document.createElement("div");
el.className = `op-pv-panel ${cls}`;
el.innerHTML = html;
return el;
};
const { leftImages, figure, quote, credits, lastImage } = PROJECT.pages;
// Reuse the page-0 text (cloned, already faded in) so the hand-off is seamless.
const leftP0 = panel("op-pv-white");
const clone = pageInner.cloneNode(true);
clone.removeAttribute("id");
clone.querySelectorAll(".op-reveal").forEach((el) => el.classList.add("is-in"));
clone.querySelector("h1").outerHTML = `<h2 class="op-title__text">${PROJECT.name}</h2>`;
leftP0.append(clone);
const leftP3 = panel(
"op-pv-white",
`<div class="op-title"><span class="op-tag">${PROJECT.name}</span><h2 class="op-title__text">Credits</h2></div>` +
`<dl class="op-credits">` +
credits.map(([k, v]) => `<div class="op-credits__row"><dt>${k}</dt><dd>${v}</dd></div>`).join("") +
`</dl>`
);
pvLeft = [leftP0, ...leftImages.map((p) => panel("op-pv-image", imgTag(p))), leftP3];
pvRight = [
panel("op-pv-image", imgTag(PROJECT.hero)),
panel("op-pv-figure", imgTag(figure)),
panel(
"op-pv-quote",
`<span class="op-pv-quote__name">${quote.name}</span><p class="op-pv-quote__text">${quote.text}</p>`
),
panel("op-pv-image", imgTag(lastImage)),
];
const leftTrack = document.createElement("div");
leftTrack.className = "op-track op-track--left";
leftTrack.append(...pvLeft);
const rightTrack = document.createElement("div");
rightTrack.className = "op-track op-track--right";
rightTrack.append(...pvRight);
pvEl.replaceChildren(leftTrack, rightTrack);
}
// Position the panels for the current page (opposite vertical directions).
// Steps are 100% of a half, so the stacked layout works too.
function renderPV(animate = true) {
const apply = (el, y) => {
if (!animate) el.style.transition = "none";
el.style.transform = `translateY(${y}%)`;
if (!animate) {
void el.offsetWidth;
el.style.transition = "";
}
};
pvLeft.forEach((el, i) => apply(el, (i - pg) * 100)); // enters from the bottom
pvRight.forEach((el, i) => apply(el, -(i - pg) * 100)); // enters from the top
}
/* State ---------------------------------------------------------------------- */
let isOpen = false; // true while the project page is shown
let entering = false; // true during the entry animation (paging locked)
let busy = false; // true while the image is moving (open / close)
let pg = 0; // current project page
let pvCooldownUntil = 0; // gates paging to one page per gesture
let autoTimer = null;
const timers = [];
// Read a CSS time custom property (e.g. "820ms") as a number of ms.
function cssMs(name) {
const v = getComputedStyle(stage).getPropertyValue(name).trim();
return v.endsWith("ms") ? parseFloat(v) : parseFloat(v) * 1000;
}
function later(fn, ms) {
timers.push(setTimeout(fn, ms));
}
function clearTimers() {
timers.forEach(clearTimeout);
timers.length = 0;
}
function heroTransition() {
return ["top", "left", "width", "height", "border-radius"]
.map((p) => `${p} var(--hero-dur) var(--ease)`)
.join(", ");
}
// The thumbnail's resting box, relative to the right panel.
function slotRect() {
const s = slotEl.getBoundingClientRect();
const p = rightPanel.getBoundingClientRect();
return { left: s.left - p.left, top: s.top - p.top, w: s.width, h: s.height };
}
function setBox(el, b) {
el.style.left = `${b.left}px`;
el.style.top = `${b.top}px`;
el.style.width = `${b.w}px`;
el.style.height = `${b.h}px`;
}
/* Open: shared-element entry ------------------------------------------------ */
function open() {
if (isOpen || busy) return;
clearTimeout(autoTimer);
isOpen = true;
entering = true;
busy = true;
pg = 0;
stage.classList.add("is-open");
projectPage.setAttribute("aria-hidden", "false");
thumbEl.classList.remove("is-current"); // stop the hover state
thumbEl.setAttribute("aria-hidden", "true");
thumbEl.tabIndex = -1;
// Pin the hero to its exact pixel box first (no transform), then animate plain
// top / left / width / height — avoids a jump at the start of the expansion.
thumbEl.style.transition = "none";
thumbEl.style.transform = "none";
setBox(thumbEl, slotRect());
void thumbEl.offsetWidth;
// Now expand to fill the right half.
thumbEl.style.transition = heroTransition();
thumbEl.style.left = "0px";
thumbEl.style.top = "0px";
thumbEl.style.width = "100%";
thumbEl.style.height = "100%";
thumbEl.style.borderRadius = "0px"; // the rounded thumbnail squares off
// Fade the page-0 text in one block at a time once the white has settled.
const revealMs = cssMs("--reveal-dur");
const stagger = reducedMotion ? 0 : SECTION_STAGGER;
revealEls.forEach((el) => el.classList.remove("is-in"));
revealEls.forEach((el, i) => later(() => el.classList.add("is-in"), revealMs + i * stagger));
later(() => (busy = false), cssMs("--hero-dur") + 40);
// Reveal the paging layer (at page 0) once the entry has fully assembled.
renderPV(false);
const entryMs = revealMs + (revealEls.length - 1) * stagger + (reducedMotion ? 0 : SECTION_FADE);
later(() => {
pvEl.classList.add("is-shown");
pvEl.setAttribute("aria-hidden", "false");
entering = false;
}, entryMs);
}
/* Close: back to the entry state -------------------------------------------- */
function close() {
if (!isOpen || busy) return;
isOpen = false;
entering = false;
busy = true;
clearTimers();
// Fade the paging layer away (back to the page-0 underlay), then contract.
if (pvEl.classList.contains("is-shown")) {
pvEl.classList.remove("is-shown");
pvEl.setAttribute("aria-hidden", "true");
later(() => {
pg = 0;
renderPV(false);
contractHero();
}, reducedMotion ? 0 : PV_FADE_OUT);
} else {
contractHero();
}
}
function contractHero() {
stage.classList.remove("is-open");
projectPage.setAttribute("aria-hidden", "true");
revealEls.forEach((el) => el.classList.remove("is-in"));
// Contract back to the exact box it started from, mirroring the expansion.
thumbEl.style.transition = heroTransition();
setBox(thumbEl, slotRect());
thumbEl.style.borderRadius = "";
later(() => {
// Hand control back to the CSS resting state instantly (no flick).
thumbEl.style.transition = "none";
thumbEl.removeAttribute("style");
void thumbEl.offsetWidth;
thumbEl.style.transition = "";
thumbEl.classList.add("is-current");
thumbEl.removeAttribute("aria-hidden");
thumbEl.tabIndex = 0;
busy = false;
}, cssMs("--hero-dur") + 40);
}
/* Paging ---------------------------------------------------------------------- */
function pvStep(dir) {
const next = pg + dir;
if (next < 0) {
close(); // stepping up past the first page → back to the thumbnail
return;
}
if (next > pvLeft.length - 1) return; // last page: stay
pg = next;
renderPV(true);
}
/* Wire it up ------------------------------------------------------------------ */
buildPV();
renderPV(false);
new StepScroll({
target: document.body,
onStep: (dir) => {
if (!isOpen) {
if (dir > 0) open(); // scrolling down on the entry state opens the project
return;
}
if (entering) return;
// One page per gesture: ignore further steps until the slow push has finished.
const now = performance.now();
if (now < pvCooldownUntil) return;
pvCooldownUntil = now + cssMs("--pv-dur");
pvStep(dir);
},
});
thumbEl.addEventListener("click", open);
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
});
// Open on its own once, shortly after the hero image has loaded.
const whenLoaded = heroImg.complete
? Promise.resolve()
: new Promise((res) => {
heroImg.addEventListener("load", res, { once: true });
heroImg.addEventListener("error", res, { once: true });
});
whenLoaded.then(() => {
autoTimer = setTimeout(open, AUTO_OPEN_DELAY);
});
</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>
원본 구현 프롬프트
# Opposing Pager: a thumbnail expands into a split project page whose halves page in opposite directions
**Goal:** build a full-screen split project view where a thumbnail grows into the right half of the screen, the left half fills with the project text, and each scroll step then pushes the left and right halves in opposite vertical directions, one page at a time.
## Visual behaviour
### 1. Entry state
- The screen is split into two halves (50% / 50%), white, no scrolling.
- **Left half:** the project name, large (`clamp(2.6rem, 5vw, 4.6rem)`, regular weight, line-height 1.02, letter-spacing −0.01em), vertically centred and inset 5.4vw from the left, with a small grey uppercase category tag under it (0.7rem, medium, tracking 0.25em, margin-top 1.1rem). Two static 1px light-grey (#e6e6e6) hairlines frame it, at `50% ± 0.7 × 18vh`, max 21rem wide.
- **Right half:** a 3:4 thumbnail centred in the half, width `min(44vw, 64vh × 3/4)`, 6px rounded corners, `object-fit: cover`.
- **Hover on the thumbnail:** the image zooms to `scale(1.05)` over 600 ms, and an overlay (black at 18%) fades in over 450 ms with a thin white plus at its centre (40 × 40 px, 1.5px lines). All with the main ease.
### 2. Open (click the thumbnail, scroll down, or automatically 1 s after the image has loaded)
- **Shared element:** reuse the very same image element. First pin it, with transitions off, to its exact current pixel box (top / left / width / height relative to the right half, no transform), force a reflow, then transition `top`, `left`, `width`, `height` and `border-radius` to fill the whole right half (0, 0, 100%, 100%, radius 0) over **950 ms**. This avoids a jump at the start.
- **At the same time** a white block, covering the left half and sitting just above the viewport (`translateY(-100%)`), slides down to `translateY(0)` over **820 ms**, covering the name.
- **Text reveal:** once the white block has settled (820 ms after opening), the page-0 content fades in one block at a time, **220 ms** apart: first the title (category tag + project name, top left), then two text sections (a small uppercase heading + a paragraph, each with a hairline under it) seated in the bottom-left corner. Each block goes from opacity 0 / `translateY(8px)` to 1 / 0 over **720 ms**.
- When the last block has finished (820 + 2 × 220 + 720 = 1980 ms), the paging layer takes over (see below). Lock paging until then.
### 3. Paging
- The paging layer covers the screen and holds two tracks, one per half. Each track holds 4 full-half panels stacked on top of each other with `position: absolute`.
- Page 0 replicates the opened state exactly (left: title + sections, already visible; right: the hero image full-bleed), so the hand-off from the entry is invisible. The layer itself fades in over 350 ms.
- Panels: left track `translateY((i − page) × 100%)`, right track `translateY(−(i − page) × 100%)`. So going forward, the **new left page enters from the bottom** while the **new right page enters from the top**; the halves move in opposite directions.
- Each push takes **1725 ms** with the main ease (`transform` transition).
- Pages:
- 0: title + sections | hero image
- 1: full-bleed image | a smaller 3:4 photo (58% of the half's height, 6px corners) floating in white
- 2: full-bleed image | a centred attribution (small uppercase) above a quote (`clamp(1.25rem, 1.8vw, 1.7rem)`, line-height 1.5, max 24rem)
- 3: credits (a title + a list of label / value rows with hairlines) | full-bleed image
- **One page per gesture:** after a step, ignore further steps until the 1725 ms push has finished. On the last page, a forward step does nothing.
- **Input → steps:** accumulate wheel `deltaY`; once it reaches ±14, commit one step (and reset the accumulator), with at least 55 ms between commits. Touch: every 28 px of vertical travel commits a step. Keys: ArrowDown / PageDown = +1, ArrowUp / PageUp = −1. Prevent the default scroll.
### 4. Close (step back from page 0, or Escape)
- The paging layer fades out (360 ms), revealing the page-0 underlay; the pages reset to page 0 without animation.
- Then the white block slides back up (820 ms), the text blocks fade out, and the image animates `top / left / width / height / border-radius` back to its measured thumbnail box (950 ms). After that, hand control back to the CSS resting state with transitions off so nothing flicks.
- Block new opens or closes while the image is still moving.
## Exact values
| What | Value |
|---|---|
| Ease (everything) | `cubic-bezier(0.16, 1, 0.3, 1)` |
| Image expand / contract | 950 ms (top, left, width, height, border-radius 6px ↔ 0) |
| White block slide | 820 ms, `translateY(-100%)` → 0 |
| Text blocks | 720 ms each, 220 ms stagger, from 8px below, start after 820 ms |
| Page push | 1725 ms, 100% of a half per page, opposite directions |
| Paging layer fade | 350 ms in; 360 ms out before closing |
| Auto-open | 1 s after the hero image loads (optional) |
| Step input | wheel ±14 delta, touch 28 px, 55 ms min between steps |
| Thumbnail | 3:4, `min(44vw, 64vh × 3/4)` wide, centred in the right half |
| Text insets | x: page gutter aligned to a centred max-width column; sections 2.4rem from the bottom; title `clamp(96px, 15vh, 150px)` from the top |
## Structure
```html
<div class="stage"> <!-- fixed, inset 0, overflow hidden -->
<div class="split">
<section class="panel left"> name + tag + 2 hairlines </section>
<section class="panel right">
<span class="thumb slot"></span> <!-- invisible copy of the resting box, measured on close -->
<button class="thumb"><img><span class="overlay"><span class="plus"></span></span></button>
</section>
</div>
<div class="page"><div class="page-inner"> title + sections </div></div> <!-- white block, left half -->
<div class="pv"> <!-- paging layer -->
<div class="track left"> 4 panels </div>
<div class="track right"> 4 panels </div>
</div>
</div>
```
- Measure the thumbnail box from the hidden slot element (not the moving image), so the close always returns to the right place even after a resize.
- Build the paging panels from a data object (copy, images), and clone the page-0 text into the first left panel already in its visible state.
## Libraries
None: CSS transitions for every movement and a small JS state machine (open / close / page, with timers and locks). GSAP or Motion could drive the same timeline, but plain transitions are enough.
## Responsive and reduced motion
- On phones and tall portrait screens (`max-width: 600px` or `max-aspect-ratio: 4/5`) the halves stack top / bottom (each 50% of the height). The white block covers the top half, the thumbnail is `min(100vw − 2 × gutter, 32vh × 3/4)` wide, and only the first text section is shown. The tracks still push vertically in opposite directions, so new pages open from the seam.
- Very short screens (< 600px tall) show only the first section.
- `prefers-reduced-motion: reduce`: all durations become ~1 ms and the text stagger 0, so every state change is instant, but the open / page / close logic is unchanged.
## Acceptance criteria
- The thumbnail grows smoothly into the right half without any jump at the start, its corners squaring off, while white slides down over the left half.
- The title and the two sections fade in one after another once the white has settled.
- Each wheel flick, swipe or arrow key moves exactly one page; the left half moves up while the right half moves down (and the reverse going back), slowly and in sync.
- Stepping back from the first page, or Escape, fades the pager and shrinks the image back into its exact thumbnail box.
- Fast repeated input never breaks the state or skips pages.
- Stacked layout on phones works with the same opposite push.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: aa8cef524ec8367412a7babc2f6e0793f8490eed3ec7625ee4baad8d13713dcd