모션·인터랙션 / Cadence
Panning Slideshow
갤러리 · 이미지 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Panning Slideshow
A full-screen hero carousel. Each photo pans slowly sideways (linear, 5 s) and the next
photo pushes in over it before the pan ends, so the change happens while the image is
still moving. Entrances alternate: up, right, up, right… The heading and paragraph fade
in with a stagger and fade out just before the next slide arrives. The numbers at the
bottom jump to any slide (the running cycle is cancelled cleanly).
Dependencies: none (Web Animations API + CSS transitions).
Tweak (in the script):
- PAN_MS: 5000 (pan length = time per slide)
- TRANSITION_MS: 700 (push-in), ENTRANCE_EASE: cubic-bezier(0.65, 0, 0.35, 1)
- TEXT_FADE_MS: 550, TEXT_STAGGER_MS: 200, TEXT_DELAY_MS: 80
- PAN_BOX: 1.08 (image width as a multiple of the frame), PAN_RANGE: 0.75 and
PAN_CENTER: 0.5 (how much of the extra width is travelled, and around which point)
- ENTRANCE_PATTERN: ["up", "right"]
- SLIDE_CONTENT: the captions, one per slide
From the Casa Alba site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panning Slideshow</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #fff; font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
h1, p { margin: 0; }
.ps-hero {
--text-fade: 0.55s;
--ease-soft: cubic-bezier(0.65, 0, 0.35, 1);
position: relative;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: #000;
color: #fff;
container-type: size; /* the image width is sized against the frame */
}
/* Slides */
.ps-slides { position: absolute; inset: 0; }
.ps-slide {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translate3d(0, 0, 0); /* moved by the script for the entrance */
will-change: transform, opacity;
}
.ps-slide.is-active { opacity: 1; visibility: visible; z-index: 2; }
.ps-slide.is-outgoing { opacity: 1; visibility: visible; z-index: 1; }
/* The image box is wider than the frame and panned by the script */
.ps-image {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: calc(100cqw * var(--pan-box, 1.08));
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
will-change: transform;
transform: translate3d(0, 0, 0);
}
/* Darkening overlays so the white text reads */
.ps-overlay { position: absolute; inset: 0; z-index: 5; pointer-events: none; }
.ps-overlay--flat { background: rgba(0, 0, 0, 0.5); }
.ps-overlay--top { background: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0) 50%); opacity: 0.5; }
.ps-overlay--bottom { background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, #000 100%); opacity: 0.5; }
/* Centred caption */
.ps-content {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 2rem;
text-align: center;
pointer-events: none;
}
.ps-heading,
.ps-paragraph {
font-weight: 400;
color: #fff;
opacity: 0;
transform: translateY(8px);
transition:
opacity var(--text-fade) var(--ease-soft),
transform var(--text-fade) var(--ease-soft);
}
.ps-heading { font-size: clamp(1.75rem, 3.2vw, 2.75rem); line-height: 1.15; letter-spacing: 0.01em; }
.ps-paragraph { margin-top: 1.5rem; max-width: 34rem; font-size: 1rem; line-height: 1.7; }
.ps-heading.is-visible,
.ps-paragraph.is-visible { opacity: 1; transform: translateY(0); }
/* Pagination */
.ps-pagination {
position: absolute;
left: 0;
right: 0;
bottom: clamp(1.5rem, 4vh, 3rem);
z-index: 10;
display: flex;
justify-content: center;
gap: 1rem;
font-size: 0.875rem;
letter-spacing: 0.18em;
color: #8e8e8e;
}
.ps-page {
padding: 0.25rem calc(0.5rem - 0.18em) 0.25rem 0.5rem;
border: 0;
background: none;
font: inherit;
letter-spacing: inherit;
color: inherit;
cursor: pointer;
transition: color 0.5s var(--ease-soft);
}
.ps-page:hover,
.ps-page.is-active { color: #fff; }
.ps-page:focus-visible { outline: 1px solid #fff; outline-offset: 2px; }
@media (max-width: 800px) {
.ps-content { padding: 0 clamp(1.5rem, 7vw, 2.5rem); }
.ps-heading { font-size: clamp(1.4rem, 6vw, 1.85rem); line-height: 1.2; }
.ps-paragraph { margin-top: clamp(0.85rem, 2.5vh, 1.25rem); font-size: clamp(0.85rem, 3.4vw, 0.95rem); line-height: 1.6; max-width: 32rem; }
.ps-pagination { bottom: clamp(1.75rem, 5vh, 2.75rem); gap: clamp(0.5rem, 2vw, 1rem); }
.ps-page { display: inline-flex; align-items: center; justify-content: center; min-width: 2.25rem; min-height: 2.25rem; padding: 0.6rem 0.65rem; }
}
</style>
</head>
<body>
<section class="ps-hero" id="hero" aria-label="Featured places" aria-roledescription="carousel">
<!-- Swap the picsum.photos placeholders for your own landscape (3:2) photos. -->
<div class="ps-slides">
<div class="ps-slide is-active" role="img" aria-label="Slide 1">
<div class="ps-image" style="background-image: url('https://picsum.photos/seed/cadence-1/1600/1067');"></div>
</div>
<div class="ps-slide" role="img" aria-label="Slide 2">
<div class="ps-image" style="background-image: url('https://picsum.photos/seed/cadence-2/1600/1067');"></div>
</div>
<div class="ps-slide" role="img" aria-label="Slide 3">
<div class="ps-image" style="background-image: url('https://picsum.photos/seed/cadence-3/1600/1067');"></div>
</div>
<div class="ps-slide" role="img" aria-label="Slide 4">
<div class="ps-image" style="background-image: url('https://picsum.photos/seed/cadence-4/1600/1067');"></div>
</div>
</div>
<div class="ps-overlay ps-overlay--flat" aria-hidden="true"></div>
<div class="ps-overlay ps-overlay--top" aria-hidden="true"></div>
<div class="ps-overlay ps-overlay--bottom" aria-hidden="true"></div>
<div class="ps-content" aria-live="polite">
<h1 class="ps-heading" data-hero-heading>Lime and Stone</h1>
<p class="ps-paragraph" data-hero-paragraph>Morning light settles on whitewashed walls and worn paving. A bench, a potted olive, a door left open: nothing here asks to be noticed.</p>
</div>
<div class="ps-pagination" role="group" aria-label="Slides">
<button type="button" class="ps-page is-active" data-page="0" aria-label="Go to slide 1" aria-current="true">01</button>
<button type="button" class="ps-page" data-page="1" aria-label="Go to slide 2">02</button>
<button type="button" class="ps-page" data-page="2" aria-label="Go to slide 3">03</button>
<button type="button" class="ps-page" data-page="3" aria-label="Go to slide 4">04</button>
</div>
</section>
<script>
/* Timings and constants ---------------------------------------------------- */
const PAN_MS = 5000; // slow horizontal pan (= time per slide)
const TRANSITION_MS = 700; // incoming-slide entrance
const TEXT_FADE_MS = 550; // heading / paragraph fades
const TEXT_STAGGER_MS = 200; // gap between heading & paragraph fade-in
const TEXT_DELAY_MS = 80; // heading appears this long after the pan starts
const PAN_BOX = 1.08; // image box width as a multiple of the frame width
const PAN_RANGE = 0.75; // fraction of the extra width the pan travels
const PAN_CENTER = 0.5; // centre of the pan (0 = left edge framed, 1 = right edge framed)
const ENTRANCE_EASE = "cubic-bezier(0.65, 0, 0.35, 1)";
const ENTRANCE_PATTERN = ["up", "right"];
const SLIDE_CONTENT = [
{ heading: "Lime and Stone", paragraph: "Morning light settles on whitewashed walls and worn paving. A bench, a potted olive, a door left open: nothing here asks to be noticed." },
{ heading: "Olive Grove", paragraph: "Old trees stand in the hush of an early fog, their trunks turned and knotted by a century of wind. The grove keeps its own slow time." },
{ heading: "White Cliffs", paragraph: "Chalk falls away into a calm grey sea. Grass, sky and water meet in long, quiet bands, and the eye is free to rest on the horizon." },
{ heading: "Lavender Rows", paragraph: "Furrows of lavender lead to a stone farmhouse at the edge of the field. Late summer, the air warm and heavy with scent." },
];
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Pan endpoints as fractions of the overflow (the pan moves right to left).
const PAN_FROM = Math.max(0, Math.min(1, PAN_CENTER + PAN_RANGE / 2));
const PAN_TO = Math.max(0, Math.min(1, PAN_CENTER - PAN_RANGE / 2));
/* Setup ---------------------------------------------------------------------- */
const hero = document.getElementById("hero");
const slides = Array.from(hero.querySelectorAll(".ps-slide"));
const pages = Array.from(hero.querySelectorAll(".ps-page"));
const headingEl = hero.querySelector("[data-hero-heading]");
const paragraphEl = hero.querySelector("[data-hero-paragraph]");
hero.style.setProperty("--text-fade", `${TEXT_FADE_MS}ms`);
hero.style.setProperty("--pan-box", PAN_BOX);
let imageOverflow = 0;
const computeOverflow = () => {
const sample = slides[0].querySelector(".ps-image");
imageOverflow = Math.max(0, sample.clientWidth - hero.clientWidth);
};
computeOverflow();
window.addEventListener("resize", computeOverflow);
const panOffsetFor = (fraction) => -fraction * imageOverflow;
const setText = (idx) => {
headingEl.textContent = SLIDE_CONTENT[idx].heading;
paragraphEl.textContent = SLIDE_CONTENT[idx].paragraph;
};
const showText = () => headingEl.classList.add("is-visible");
const showParagraph = () => paragraphEl.classList.add("is-visible");
const hideText = () => {
headingEl.classList.remove("is-visible");
paragraphEl.classList.remove("is-visible");
};
const updatePagination = (idx) => {
pages.forEach((p, i) => {
p.classList.toggle("is-active", i === idx);
if (i === idx) p.setAttribute("aria-current", "true");
else p.removeAttribute("aria-current");
});
};
const seedImageAtStart = (slide) => {
const img = slide.querySelector(".ps-image");
img.getAnimations().forEach((a) => a.cancel());
// Reduced motion: hold the image at the centre of the pan.
const fraction = reducedMotion ? PAN_CENTER : PAN_FROM;
img.style.transform = `translate3d(${panOffsetFor(fraction)}px, 0, 0)`;
};
const startPan = (slide) => {
if (reducedMotion) return null;
const img = slide.querySelector(".ps-image");
img.getAnimations().forEach((a) => a.cancel());
return img.animate(
[
{ transform: `translate3d(${panOffsetFor(PAN_FROM)}px, 0, 0)` },
{ transform: `translate3d(${panOffsetFor(PAN_TO)}px, 0, 0)` },
],
{ duration: PAN_MS, easing: "linear", fill: "forwards" }
);
};
const runEntrance = (slide, direction) =>
slide.animate(
[
{ transform: direction === "up" ? "translate3d(0, 100%, 0)" : "translate3d(100%, 0, 0)" },
{ transform: "translate3d(0, 0, 0)" },
],
{ duration: reducedMotion ? 1 : TRANSITION_MS, easing: ENTRANCE_EASE, fill: "forwards" }
);
/* Generation-based cancellation: every timer and entrance belongs to a cycle;
a click starts a new cycle and anything left over from the old one is ignored. */
let generation = 0;
const timers = new Set();
const activeAnimations = new Set();
const cleanupGeneration = () => {
timers.forEach((t) => clearTimeout(t));
timers.clear();
activeAnimations.forEach((a) => { try { a.cancel(); } catch (_) {} });
activeAnimations.clear();
};
const wait = (ms, gen) =>
new Promise((resolve) => {
const t = setTimeout(() => {
timers.delete(t);
if (gen !== generation) return; // a new cycle has started
resolve();
}, ms);
timers.add(t);
});
const trackAnim = (anim) => {
if (!anim) return null;
activeAnimations.add(anim);
anim.finished.finally(() => activeAnimations.delete(anim)).catch(() => {});
return anim;
};
/* One cycle --------------------------------------------------------------------- */
let currentIdx = 0;
let entranceStep = 0;
async function cycle(gen) {
if (gen !== generation) return;
const slide = slides[currentIdx];
// The pan isn't tracked by the generation, so after a click it keeps moving
// under the incoming slide instead of snapping back.
startPan(slide);
await wait(TEXT_DELAY_MS, gen);
showText();
await wait(TEXT_STAGGER_MS, gen);
showParagraph();
// Fade the text out so the fade ends as the entrance begins.
const entranceStartAt = Math.max(0, PAN_MS - TRANSITION_MS); // 4300 ms
const fadeOutAt = Math.max(0, entranceStartAt - TEXT_FADE_MS); // 3750 ms
await wait(Math.max(0, fadeOutAt - TEXT_DELAY_MS - TEXT_STAGGER_MS), gen);
hideText();
await wait(TEXT_FADE_MS, gen);
// The next slide pushes in while the current pan is still running.
const nextIdx = (currentIdx + 1) % slides.length;
const direction = ENTRANCE_PATTERN[entranceStep % ENTRANCE_PATTERN.length];
await transitionInto(nextIdx, direction, gen);
}
async function transitionInto(nextIdx, direction, gen) {
if (gen !== generation) return;
const outgoing = slides[currentIdx];
const incoming = slides[nextIdx];
seedImageAtStart(incoming);
incoming.classList.add("is-outgoing");
incoming.style.zIndex = "3";
const entrance = trackAnim(runEntrance(incoming, direction));
try {
await entrance.finished;
} catch (_) {
return; // cancelled by a click
}
if (gen !== generation) return;
outgoing.classList.remove("is-active");
outgoing.style.zIndex = "";
outgoing.style.transform = "";
seedImageAtStart(outgoing); // ready for its next turn
incoming.classList.remove("is-outgoing");
incoming.style.zIndex = "";
incoming.style.transform = "";
incoming.classList.add("is-active");
currentIdx = nextIdx;
entranceStep += 1;
setText(currentIdx);
updatePagination(currentIdx);
cycle(gen);
}
/* Jump to a slide (pagination) ----------------------------------------------------- */
const jumpTo = (targetIdx) => {
if (targetIdx === currentIdx) return;
generation += 1;
cleanupGeneration();
hideText();
// Reset any slide an earlier click left half-entered.
slides.forEach((s, i) => {
if (i === currentIdx || !s.classList.contains("is-outgoing")) return;
s.classList.remove("is-outgoing");
s.style.zIndex = "";
s.style.transform = "";
seedImageAtStart(s);
});
const direction = ENTRANCE_PATTERN[entranceStep % ENTRANCE_PATTERN.length];
transitionInto(targetIdx, direction, generation);
};
pages.forEach((p) => {
p.addEventListener("click", () => {
const idx = parseInt(p.dataset.page, 10);
if (!Number.isNaN(idx)) jumpTo(idx);
});
});
/* Start ----------------------------------------------------------------------------- */
slides.forEach((s, i) => {
s.classList.toggle("is-active", i === 0);
seedImageAtStart(s);
});
setText(0);
updatePagination(0);
cycle(generation);
</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>
원본 구현 프롬프트
# Panning Slideshow: hero carousel where each photo pans slowly and the next pushes in before the pan ends
**Goal:** build a full-screen hero slideshow where each photo drifts slowly sideways, the next photo pushes in over it (alternating from below and from the right) while the pan is still moving, and a centred heading and paragraph fade in and out with each slide.
## Visual behaviour
1. A full-viewport frame (`overflow: hidden`, black background) holds 4 slides stacked on top of each other. Each slide contains one landscape photo (3:2). Over the slides sit three dark overlays (a flat 50% black, plus a top and a bottom gradient), a centred caption (heading + paragraph, white) and a row of numbers at the bottom (`01 02 03 04`).
2. **The pan.** Each photo is a `background-size: cover` box that is **1.08× the frame width** and full height, so it overflows the frame by 8% of its width. While a slide is current, its photo moves **from right to left** with a **linear** 5000 ms animation:
- Overflow = image box width − frame width.
- Start offset = −0.875 × overflow, end offset = −0.125 × overflow (the pan covers 75% of the overflow, centred on the middle). That is about 6% of the frame width in 5 s.
3. **The cycle (5000 ms per slide)**, measured from the moment a slide becomes current:
- 0 ms: the pan starts.
- 80 ms: the heading fades in (opacity 0 → 1, `translateY(8px)` → 0, 550 ms, `cubic-bezier(0.65, 0, 0.35, 1)`).
- 280 ms: the paragraph fades in the same way (200 ms after the heading).
- 3750 ms: heading and paragraph fade out together (550 ms, same curve).
- 4300 ms: the next slide starts its entrance. The current pan keeps running underneath until 5000 ms.
- 5000 ms: the entrance has finished. The new slide becomes current, its caption text is swapped in, and its cycle starts.
4. **The entrance.** The incoming slide is placed on top (higher z-index) with its photo at the pan start offset, then slides in over the current one with a transform: from `translate3d(0, 100%, 0)` (up, from below) or `translate3d(100%, 0, 0)` (from the right) to `translate3d(0, 0, 0)`, **700 ms**, `cubic-bezier(0.65, 0, 0.35, 1)`. Directions alternate: up, right, up, right… After the entrance, the old slide is hidden and its photo is reset to the pan start for its next turn.
5. **Pagination.** The numbers are grey `#8e8e8e`, letter spacing 0.18em; the current one and hovered ones are white (colour transition 0.5 s). Clicking a number:
- cancels the running cycle (all pending timers and the running entrance),
- hides the caption,
- pushes the chosen slide in right away with the next direction in the pattern,
- lets the current photo's pan keep moving underneath (don't snap it back),
- resets any slide an earlier click left half-entered.
Clicking the current number does nothing.
6. Slides loop: after slide 4 comes slide 1.
## Exact values
| What | Value |
|---|---|
| Time per slide / pan | 5000 ms, linear |
| Entrance | 700 ms, `cubic-bezier(0.65, 0, 0.35, 1)`, starts at 4300 ms |
| Entrance directions | up (from 100% below), right (from 100% right), alternating |
| Image box | 1.08 × frame width, full height, `background-size: cover`, centred |
| Pan | 75% of the overflow, centred: from −0.875 × overflow to −0.125 × overflow |
| Heading in | at 80 ms; paragraph at 280 ms |
| Text fades | 550 ms, `cubic-bezier(0.65, 0, 0.35, 1)`, opacity 0 ↔ 1 and translateY 8px ↔ 0 |
| Text out | at 3750 ms (so it is gone when the entrance starts) |
| Overlays | `rgba(0,0,0,0.5)` flat; top gradient `#000 → transparent 50%` at 0.5 opacity; bottom gradient `transparent 50% → #000` at 0.5 opacity |
| Heading | `clamp(1.75rem, 3.2vw, 2.75rem)`, line height 1.15, weight 400 |
| Paragraph | 1rem, line height 1.7, max width 34rem, 1.5rem below the heading |
| Pagination | 0.875rem, letter spacing 0.18em, 1rem gap, bottom `clamp(1.5rem, 4vh, 3rem)` |
## Markup and structure
```html
<section class="hero" aria-roledescription="carousel">
<div class="slides">
<div class="slide is-active" role="img" aria-label="…"><div class="image" style="background-image:url(…)"></div></div>
… 3 more slides
</div>
<div class="overlay overlay--flat"></div>
<div class="overlay overlay--top"></div>
<div class="overlay overlay--bottom"></div>
<div class="content" aria-live="polite">
<h1 class="heading">…</h1>
<p class="paragraph">…</p>
</div>
<div class="pagination">
<button data-page="0" aria-current="true">01</button> … <button data-page="3">04</button>
</div>
</section>
```
- Slides: `position: absolute; inset: 0; overflow: hidden`, hidden by default (`opacity: 0; visibility: hidden`). The current slide is visible at z-index 2; the incoming one is visible at z-index 3 during its entrance.
- Size the image box against the frame, not the viewport: `container-type: size` on the hero and `width: calc(100cqw * 1.08)` on the image (or measure in JS). Recompute the overflow on resize.
- Keep the caption texts in a JS array, one heading and paragraph per slide, and swap them when a slide becomes current.
## Libraries
- None needed. Use the **Web Animations API** (`element.animate`) for the pan (`fill: "forwards"`) and the entrance, and CSS transitions (toggled with an `is-visible` class) for the text.
- Use **generation-based cancellation**: a counter that increments on every click. Every timer belongs to a generation; when it fires and the generation has changed, it does nothing. Track the entrance animation so a click can cancel it, and catch the rejected `finished` promise. Don't track the pan, so it keeps moving after a click.
- GSAP works just as well (a timeline per cycle, killed on click) if the project already uses it.
## Responsive and reduced motion
- Below 800px: heading `clamp(1.4rem, 6vw, 1.85rem)` with line height 1.2; paragraph `clamp(0.85rem, 3.4vw, 0.95rem)`, line height 1.6, max width 32rem; side padding `clamp(1.5rem, 7vw, 2.5rem)`; pagination buttons at least 2.25rem square for touch.
- `prefers-reduced-motion: reduce`: no pan (each photo holds at the centre of its pan range) and the entrance takes 1 ms, so slides swap instead of pushing in. The cycle timing stays the same.
## Acceptance criteria
- Each photo pans slowly and steadily from right to left for 5 s; the motion never stops or snaps while a slide is visible.
- The next slide starts pushing in at 4.3 s while the current photo is still panning, alternating from below and from the right.
- The heading appears 80 ms into each slide, the paragraph 200 ms later, and both have faded out by the time the next slide starts moving.
- The active number is white, the others grey; clicking another number pushes that slide in immediately with no leftover timers firing later and no slide stuck half-way.
- Rapid clicks on different numbers never leave two slides on top of each other.
- With reduced motion, photos are static and slides swap without movement.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: f514276b047a3d3544075596167d7d47b321f551cfeb6e470471119b98b82864