모션·인터랙션 / Cadence
Infinite Scroll Transition
스크롤 · 패럴랙스 · 갤러리 · 페이지 전환 · 영상 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Infinite Scroll Transition
An endless, staggered column of photos driven by wheel and touch. Scroll input moves an
eased playhead through a paused GSAP vertical-loop timeline; each slide also gets its own
speed factor for parallax depth, and slides fade in (image, then caption character by
character) as they enter the viewport. Clicking a photo morphs it with GSAP Flip into a
full-height preview on the left while the other slides fade out and the title and text
fade in character by character, line after line. Close (button or Esc) fits the preview
back into its thumbnail.
Dependencies: GSAP 3.15 with Observer, Flip and SplitText, from jsDelivr.
Tweak (constants at the top of the script):
- PARALLAX_SPEEDS: [1.3, 0.8, 1.15, 0.7, 1.25, 0.85] (> 1 faster, < 1 slower), cycled
- PX_PER_SECOND: 100 (loop travel per playhead second; 1px of wheel = 1px of travel)
- SCRUB_DURATION / SCRUB_EASE: 0.75s "power3.out" (the inertia after each wheel event)
- REVEAL_*: image 1s power2.out, caption chars 0.4s each, 0.025s apart, 0.12s per slide
- OPEN_FLIP: 1.2s "power4.inOut" (morph), text from 0.8s, 1s power3.out, 0.06s per line
- CLOSE_FIT: 1s "power3.inOut"
- SLIDES: title, text, sideways offset (stagger, vw) and width (vw) of every slide
- .gallery gap: 12vh (also the padding at the loop's wrap seam)
Based on "Infinite Scroll Gallery and Flip Transitions in GSAP" by Surya Aditya for Codrops
https://tympanus.net/codrops/?p=118084 · https://tympanus.net/Tutorials/InfiniteScrollGSAPGallery/
https://github.com/surya-aditya/codrops-infinite-scroll-and-content-transition
MIT License · Copyright (c) Surya Aditya / Codrops
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Infinite Scroll Transition</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overscroll-behavior: none;
touch-action: none;
}
/* The slider drives the gallery, so the document itself never scrolls */
.stage {
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none;
}
/* Hidden until images and fonts are ready (SplitText measures the final lines) */
body:not(.is-ready) .stage { visibility: hidden; }
/* Gallery */
.gallery {
--w-scale: 1; /* narrow screens enlarge the slides */
--x-scale: 1; /* and tighten the sideways stagger */
display: flex;
flex-direction: column;
align-items: center;
gap: 12vh; /* also the loop's padding at the wrap seam (read by the script) */
width: 100%;
padding-top: 14vh;
}
.gallery__slide {
flex-shrink: 0;
margin: 0;
padding: 0;
transform: translateX(calc(var(--stagger, 0vw) * var(--x-scale)));
cursor: pointer;
outline: none;
}
.gallery__slide:focus-visible .gallery__img-wrapper {
outline: 1px solid #000;
outline-offset: 4px;
}
.gallery__slide figcaption {
display: block;
margin-top: 6px;
font-size: 12px;
line-height: 1.5;
color: #8e8e8e;
}
.gallery__img-wrapper {
position: relative;
width: calc(var(--img-w, 200px) * var(--w-scale));
height: auto;
aspect-ratio: 0.8;
overflow: hidden;
border-radius: 6px;
pointer-events: none;
user-select: none;
}
.gallery__img-wrapper img,
.gallery__img-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.2);
}
@media (max-width: 767px) {
.gallery {
--w-scale: 1.6;
--x-scale: 0.9;
}
}
/* Detail view (fixed overlay) */
.content {
display: none;
position: fixed;
inset: 0;
z-index: 1;
}
/* The clicked image lands here: full height, pinned to the left edge */
.content__preview-img {
position: fixed;
top: 0;
left: 0;
height: 100vh;
aspect-ratio: 0.8;
margin: 0;
overflow: hidden;
}
.content__preview-img img,
.content__preview-img video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.2);
}
.content__group-list {
display: flex;
flex-direction: column;
min-height: 100vh;
padding-inline: calc(80vh + 1.5vw) clamp(16px, 2.5vw, 32px);
padding-block: 24px clamp(16px, 2.5vw, 32px);
}
.content__back {
align-self: flex-start;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
line-height: 34px;
color: #000;
cursor: pointer;
}
.content__group { display: none; }
.content__group.active { display: block; }
.content__title {
margin-top: 40vh;
margin-bottom: 36px;
font-size: clamp(30px, 10vw, 72px);
font-weight: 400;
line-height: 1;
letter-spacing: -0.03em;
}
.content__description {
max-width: 50ch;
margin-right: 10vw;
font-size: clamp(12px, 2.5vw, 18px);
line-height: 1.5;
color: #8e8e8e;
}
/* Portrait screens stack the preview above the text */
@media (max-aspect-ratio: 4/3) {
.content__preview-img {
width: 100%;
height: auto;
max-height: 62vh;
}
.content__group-list {
padding: calc(min(125vw, 62vh) + 12px) clamp(16px, 2.5vw, 32px) clamp(16px, 2.5vw, 32px);
}
.content__title {
margin-top: 20px;
margin-bottom: 16px;
}
.content__description {
margin-right: 0;
font-size: 14px;
}
}
</style>
</head>
<body>
<!-- The script fills the gallery and the detail groups from SLIDES. -->
<div class="stage">
<div class="gallery"></div>
</div>
<!-- Detail view: the clicked photo morphs into the preview; its text group fades in. -->
<div class="content">
<figure class="content__preview-img"><img alt=""></figure>
<div class="content__group-list">
<button class="content__back" type="button">← Close</button>
</div>
</div>
<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/Observer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Flip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
<script>
gsap.registerPlugin(Observer, Flip, SplitText);
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Constants ------------------------------------------------------------- */
// Slider
const PARALLAX_SPEEDS = [1.3, 0.8, 1.15, 0.7, 1.25, 0.85]; // > 1 faster, < 1 slower
const PX_PER_SECOND = 100; // loop travel: 100px per playhead second
const SCRUB_DURATION = reducedMotion ? 0 : 0.75;
const SCRUB_EASE = "power3.out";
const RESIZE_DEBOUNCE = 200; // ms
// Slide reveal
const REVEAL_STAGGER = 0.12; // delay between slides entering together, top to bottom
const REVEAL_IMG = { duration: 1, ease: "power2.out" };
const REVEAL_CHARS = { duration: 0.4, ease: "none", stagger: 0.025, delay: 0.2 };
// Open
const OPEN_OTHERS = { duration: 0.5, ease: "power2.out" };
const OPEN_CAPTION = { duration: 0.3, ease: "power2.out" };
const OPEN_FLIP = { duration: 1.2, ease: "power4.inOut" }; // image scale 1.2 → 1 alongside
const OPEN_TEXT = { duration: 1, ease: "power3.out", stagger: 0.01 };
const OPEN_TEXT_DELAY = 0.8; // after the morph starts
const OPEN_LINE_STAGGER = 0.06;
// Close
const CLOSE_TEXT = { duration: 0.4, stagger: 0.04, ease: "power1.out" };
const CLOSE_FIT = { duration: 1, ease: "power3.inOut" }; // image scale 1 → 1.2 alongside
const CLOSE_OTHERS = { duration: 0.5, ease: "power2.out", at: 0.5 };
const CLOSE_CAPTION = { duration: 0.4, ease: "power2.out", at: 0.6 };
// Videos
const VIDEO_VIEW_MARGIN = "10% 0px"; // start playing just before a video scrolls into view
const VIDEO_READY_TIMEOUT = 6000; // ms; never wait longer than this for a video
/* Slides ----------------------------------------------------------------
Swap the picsum.photos placeholders for your own portrait images (any ratio works;
the frames are 4:5 and crop with object-fit: cover). `thumb` is shown in the gallery,
`full` is swapped into the preview once it has loaded.
A slide with `video` shows a muted, looping <video> instead. The placeholder clip is
MDN's CC0 sample (CORS-enabled); replace it with your own MP4. */
const VIDEO = "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4";
const thumb = (i) => `https://picsum.photos/seed/cadence-${i + 1}/960/1280`;
const full = (i) => `https://picsum.photos/seed/cadence-${i + 1}/1500/2000`;
// stagger: sideways offset of the slide (vw); width: width of the photo (vw)
const SLIDES = [
{ title: "Linen Hour", stagger: -24, width: 20, text: "Morning light settles on a plain linen dress and a bowl held close. Nothing in the room asks for attention, so the stillness becomes the subject." },
{ title: "Chalk Coast", stagger: 14, width: 10, text: "White cliffs fall away into a slow grey sea. The walk along the edge is long, quiet and almost entirely wind." },
{ title: "White Cape", stagger: -18, width: 23, text: "A long cape in undyed wool, cut generously and left to move. The silhouette does the talking; the details stay out of the way." },
{ title: "Olive Grove", stagger: 22, width: 18, text: "Old terraces step down the hillside, each wall laid by hand. The trees are older than the path that runs between them." },
{ title: "Quiet Room", stagger: -26, width: 25, text: "A grey cardigan, a tall window and an afternoon with no plans. Soft textures, made for staying in." },
{ title: "Sea Fog", stagger: 28, width: 28, video: VIDEO, text: "The lighthouse appears and disappears as the fog drifts in from the water. Some mornings the coast is mostly suggestion." },
{ title: "The Gallery", stagger: -20, width: 16, text: "A single bench beneath a skylight, one picture on a long white wall. Looking slowly is the whole point of the visit." },
{ title: "Lavender Rows", stagger: 8, width: 30, text: "Rows of lavender lead the eye to a stone farmhouse at the top of the field. Late summer, just before the harvest." },
{ title: "Pier in Winter", stagger: -12, width: 15, text: "A heavy scarf against the wind off the water. Grey on grey, warm against cold, made for the walk home along the pier." },
{ title: "Courtyard", stagger: 15, width: 23, text: "Whitewashed walls, a potted olive and a reader on the stone bench. The shade moves across the courtyard by the hour." },
{ title: "Camel Coat", stagger: -24, width: 24, text: "A coat in soft camel, worn open over wide cream trousers. Classic proportions, cut a little longer than usual." },
{ title: "Distant Peaks", stagger: 26, width: 16, text: "Limestone peaks rise out of the haze in layered greys. From this distance, the landscape reads like a pencil study." },
{ title: "Still Floor", stagger: -16, width: 34, text: "A black knit against a pale concrete floor. The pose is relaxed, the room is empty, and the light does the rest." },
{ title: "Old Olives", stagger: 24, width: 13, text: "Twisted trunks hold the shape of every dry summer they have survived. In the mist, the grove is almost silent." },
{ title: "Studio Portrait", stagger: -28, width: 19, text: "A dark suit, a simple stool and a white studio. A portrait about posture more than clothes." },
{ title: "Open Field", stagger: 18, width: 22, text: "A green field rolls towards a line of trees and a low grey sky. Nothing to see for miles, which is exactly the appeal." },
{ title: "The Stair", stagger: -10, width: 27, text: "A curved stair, a sand coloured coat and a slant of sun from a high window. The architecture and the tailoring share the same soft lines." },
{ title: "Afternoon Crowd", stagger: 20, width: 18, text: "A pale suit holds still while the city moves around it. Neutral tones keep the eye on the one person not rushing." },
{ title: "Grey Hour", stagger: -22, width: 19, text: "A silver beard, a black knit and a steady look into the lens. A portrait that values character over polish." },
{ title: "Two Coats", stagger: 16, width: 30, text: "Two jackets, one shoulder to lean on and a flat grey horizon. In black and white, it already feels like a memory." },
{ title: "Reading Light", stagger: -18, width: 17, text: "A deep window ledge makes the best seat in the house. A book, a knitted jumper and a slow hour of daylight." },
{ title: "Sand and Trench", stagger: 24, width: 12, text: "A trench coat catches the wind on a pale dune. The cloth moves; the figure stays calm." },
{ title: "Stone Steps", stagger: -26, width: 23, text: "A white T-shirt and wide jeans on broad granite steps. Easy clothes for an unhurried afternoon." },
{ title: "Doorway", stagger: 12, width: 26, text: "A cream coat framed by a tall white doorway. The garden behind is out of focus; the tailoring is not." },
];
// Build the gallery and the detail groups.
const galleryEl = document.querySelector(".gallery");
const groupListEl = document.querySelector(".content__group-list");
SLIDES.forEach((s, i) => {
const media = s.video
? `<video class="gallery__img gallery__video" src="${s.video}" muted loop playsinline autoplay preload="auto" disablepictureinpicture aria-label="${s.title}"></video>`
: `<img class="gallery__img" src="${thumb(i)}" data-full="${full(i)}" alt="${s.title}" width="960" height="1280">`;
galleryEl.insertAdjacentHTML(
"beforeend",
`<figure class="gallery__slide" style="--stagger: ${s.stagger}vw; --img-w: ${s.width}vw;">
<div class="gallery__img-wrapper">${media}</div>
<figcaption>${s.title}</figcaption>
</figure>`
);
groupListEl.insertAdjacentHTML(
"beforeend",
`<div class="content__group" data-index="${i}">
<div class="content__title">${s.title}</div>
<div class="content__description">${s.text}</div>
</div>`
);
});
/* Vertical loop --------------------------------------------------------- */
/** Animate items along the y-axis in a loop */
function verticalLoop(items, config) {
items = gsap.utils.toArray(items);
config = config || {};
const tl = gsap.timeline({
repeat: config.repeat,
paused: config.paused,
defaults: { ease: "none" },
onReverseComplete: () => {
// Keep the playhead in range when playing backwards past zero
tl.totalTime(tl.rawTime() + tl.duration() * 100);
},
});
const length = items.length;
const startY = 0;
const heights = [];
const yPercents = [];
const pixelsPerSecond = (config.speed || 1) * PX_PER_SECOND;
const snap = config.snap === false ? (v) => v : gsap.utils.snap(config.snap || 1);
// Measure each item and convert its y into a height-relative yPercent
gsap.set(items, {
yPercent: (i, el) => {
const h = (heights[i] = parseFloat(gsap.getProperty(el, "height", "px")));
yPercents[i] = snap(
(parseFloat(gsap.getProperty(el, "y", "px")) / h) * 100 + gsap.getProperty(el, "yPercent"),
);
return yPercents[i];
},
});
gsap.set(items, { y: 0 });
// Full container length, including the spacing at the wrap seam
const totalHeight =
items[length - 1].offsetTop +
(yPercents[length - 1] / 100) * heights[length - 1] -
startY +
items[length - 1].offsetHeight * gsap.getProperty(items[length - 1], "scaleY") +
(parseFloat(config.paddingBottom) || 0);
// Two tweens per item: exit top, then re-enter from the bottom
for (let i = 0; i < length; i++) {
const item = items[i];
const curY = (yPercents[i] / 100) * heights[i];
const distanceToStart = item.offsetTop + curY - startY;
const distanceToLoop = distanceToStart + heights[i] * gsap.getProperty(item, "scaleY");
tl.to(
item,
{
yPercent: snap(((curY - distanceToLoop) / heights[i]) * 100),
duration: distanceToLoop / pixelsPerSecond,
},
0,
).fromTo(
item,
{ yPercent: snap(((curY - distanceToLoop + totalHeight) / heights[i]) * 100) },
{
yPercent: yPercents[i],
duration: (totalHeight - distanceToLoop) / pixelsPerSecond,
immediateRender: false,
},
distanceToLoop / pixelsPerSecond,
);
}
// Pre-render every tween to avoid a first-frame jump
tl.progress(1, true).progress(0, true);
return tl;
}
/* Slider: infinite loop driven by wheel / touch ------------------------- */
class Slider {
constructor({ enabled = () => true, onToggle } = {}) {
this.enabled = enabled;
this.onToggle = onToggle; // called with the slides that entered or left the viewport
this.createLoop();
this.createParallax();
this.createScrub();
this.createObserver();
this.resize();
}
createLoop() {
// The gap between slides doubles as the loop's bottom padding
const gap = parseFloat(getComputedStyle(galleryEl).rowGap);
// A paused one-lap timeline; its playhead is the scroll position
this.loop = verticalLoop(".gallery__slide", { repeat: -1, paused: true, paddingBottom: gap });
// Wrapping the playhead past either end is what makes it endless
this.wrap = gsap.utils.wrap(0, this.loop.duration());
}
createParallax() {
this.parallax = gsap.utils.toArray(".gallery__slide").map((slide, i) => ({
el: slide,
factor: PARALLAX_SPEEDS[i % PARALLAX_SPEEDS.length] - 1,
offset: 0,
visible: false,
}));
// Scatter the resting positions too
this.applyParallax();
}
/** Offset each slide by its speed factor, report viewport enters/leaves */
applyParallax(immediate = false) {
const changes = [];
this.parallax.forEach((item) => {
const rect = item.el.getBoundingClientRect();
const loopTop = rect.top - item.offset; // position without our offset
// Offset by the speed factor; zero at the wrap point
item.offset = item.factor * (loopTop + rect.height);
gsap.set(item.el, { y: item.offset });
const top = loopTop + item.offset;
const visible = top < window.innerHeight && top + rect.height > 0;
if (visible !== item.visible) {
item.visible = visible;
changes.push({ el: item.el, visible, top });
}
});
if (changes.length) this.onToggle?.(changes, immediate);
}
/** The eased playhead that smooths scroll input into loop time */
createScrub() {
this.playhead = { time: 0 };
this.scrub = gsap.to(this.playhead, {
time: 0,
duration: SCRUB_DURATION,
ease: SCRUB_EASE,
paused: true,
onUpdate: () => {
this.loop.time(this.wrap(this.playhead.time));
this.applyParallax();
},
});
}
createObserver() {
this.observer = Observer.create({
target: window,
type: "wheel,touch",
preventDefault: true,
onChange: (self) => this.scroll(self),
});
}
resize() {
let id;
window.addEventListener("resize", () => {
clearTimeout(id);
id = setTimeout(() => this.rebuild(), RESIZE_DEBOUNCE);
});
}
/** Move the scrub target by the scrolled distance */
scroll({ deltaX, deltaY }) {
if (!this.enabled()) return;
// Swipes (x or y) all drive the gallery
const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
// px / 100 gives playhead time
this.scrub.vars.time += delta / PX_PER_SECOND;
this.scrub.invalidate().restart();
}
/** Freeze the inertia in place so a transition can take over */
freeze() {
this.scrub.pause();
this.scrub.vars.time = this.playhead.time;
this.scrub.invalidate();
}
stop() {
this.observer.disable();
this.freeze();
}
start() {
this.observer.enable();
}
/** Re-measure and rebuild the loop, preserving the current position */
rebuild() {
const progress = this.loop.progress();
this.freeze();
this.loop.kill();
gsap.set(".gallery__slide", { clearProps: "transform" });
this.createLoop();
this.loop.progress(progress, true);
// Re-measured rather than travelled, so the reveals snap, not replay
this.parallax.forEach((item) => (item.offset = 0));
this.applyParallax(true);
this.playhead.time = this.loop.time();
this.scrub.vars.time = this.playhead.time;
this.scrub.invalidate();
}
}
/* Reveal: fade each slide in as it enters the viewport ------------------ */
class Reveal {
constructor() {
this.items = new Map();
gsap.utils.toArray(".gallery__slide").forEach((slide) => {
const wrapper = slide.querySelector(".gallery__img-wrapper");
const chars = new SplitText(slide.querySelector("figcaption"), { type: "chars" }).chars;
// Resting state: image and caption invisible
gsap.set(wrapper, { autoAlpha: 0 });
gsap.set(chars, { autoAlpha: 0 });
this.items.set(slide, { wrapper, chars });
});
}
/** Show slides that entered, top to bottom; reset the ones that left */
toggle(changes, immediate = false) {
changes
.filter((c) => c.visible)
.sort((a, b) => a.top - b.top)
.forEach((c, i) => this.show(c.el, i * REVEAL_STAGGER, immediate || reducedMotion));
changes.filter((c) => !c.visible).forEach((c) => this.hide(c.el));
}
show(slide, delay, immediate = false) {
const { wrapper, chars } = this.items.get(slide);
if (immediate) {
gsap.set([wrapper, ...chars], { autoAlpha: 1, overwrite: true });
return;
}
gsap.to(wrapper, { autoAlpha: 1, ...REVEAL_IMG, delay, overwrite: true });
gsap.to(chars, {
autoAlpha: 1,
duration: REVEAL_CHARS.duration,
ease: REVEAL_CHARS.ease,
stagger: REVEAL_CHARS.stagger,
delay: delay + REVEAL_CHARS.delay,
overwrite: true,
});
}
/** Reset instantly so the reveal replays on the next entry */
hide(slide) {
const { wrapper, chars } = this.items.get(slide);
gsap.set(wrapper, { autoAlpha: 0, overwrite: true });
gsap.set(chars, { autoAlpha: 0, overwrite: true });
}
}
/* Transition: image-to-content morph ------------------------------------ */
class Transition {
constructor({ onClose, onChange } = {}) {
this.onClose = onClose; // called once the gallery is free again
this.onChange = onChange; // state or preview media changed (video playback)
this.content = document.querySelector(".content");
this.preview = document.querySelector(".content__preview-img");
this.previewImg = this.preview.querySelector("img");
this.groups = gsap.utils.toArray(".content__group");
this.slides = gsap.utils.toArray(".gallery__slide");
this.activeSlide = null;
this.tl = null;
this.split = null;
this.state = "closed"; // closed | opening | open | closing
// The element shown in the preview: the <img>, or a slide's moved <video>
this.previewMedia = this.previewImg;
this.video = null;
this.preview.dataset.flipId = "preview";
}
setState(state) {
this.state = state;
this.onChange?.();
}
async open(slide, index) {
if (this.state !== "closed") return;
this.setState("opening");
this.activeSlide = slide;
await this.fillContent(slide, index);
// A close() landing while the preview decoded has already reset us
if (this.state !== "opening") return;
const { wrapper, caption, others } = this.parts();
wrapper.dataset.flipId = "preview";
// Capture the thumbnail's bounds before the layout changes
const state = Flip.getState(wrapper, { props: "borderRadius" });
// Show the detail layout and hide the thumbnail, killing its reveal tween
gsap.set(this.content, { display: "block" });
gsap.killTweensOf(wrapper);
gsap.set(wrapper, { autoAlpha: 0 });
// A video slide hands its playing <video> to the preview (same frame, no seek)
if (this.video) {
this.previewImg.style.display = "none";
this.preview.append(this.video);
this.previewMedia = this.video;
this.onChange?.();
}
this.split = new SplitText(".content__back, .content__group.active > *", {
type: "lines,chars",
charsClass: "char",
});
this.tl = gsap
.timeline({
onComplete: () => this.setState("open"),
onReverseComplete: () => this.reset(), // a cancelled open finished rewinding
})
.to(others, { autoAlpha: 0, ...OPEN_OTHERS }, 0)
.to(caption, { autoAlpha: 0, ...OPEN_CAPTION }, 0)
.add(
Flip.from(state, {
targets: this.preview,
...OPEN_FLIP,
absolute: true,
props: "borderRadius", // rounded thumbnail → square preview
}),
0,
)
.to(this.previewMedia, { scale: 1, ...OPEN_FLIP }, 0);
// Fade the text in character by character, one line after another
this.split.lines.forEach((line, i) => {
this.tl.fromTo(
line.querySelectorAll(".char"),
{ autoAlpha: 0 },
{ autoAlpha: 1, ...OPEN_TEXT },
OPEN_TEXT_DELAY + i * OPEN_LINE_STAGGER,
);
});
if (reducedMotion) this.tl.progress(1);
}
close() {
// Close mid-animation
if (this.state === "opening") {
this.setState("closing");
if (!this.tl) {
this.reset(); // still waiting on the decode, nothing to rewind
return;
}
this.tl.reverse();
return;
}
if (this.state !== "open") return;
this.setState("closing");
const { wrapper, caption, others } = this.parts();
this.tl = gsap
.timeline({ onComplete: () => this.reset() })
.to(this.split.lines, { autoAlpha: 0, ...CLOSE_TEXT }, 0)
.add(
Flip.fit(this.preview, wrapper, {
...CLOSE_FIT,
absolute: true,
props: "borderRadius",
}),
0,
)
.to(this.previewMedia, { scale: 1.2, ...CLOSE_FIT }, 0)
.to(others, { autoAlpha: 1, duration: CLOSE_OTHERS.duration, ease: CLOSE_OTHERS.ease }, CLOSE_OTHERS.at)
.to(caption, { autoAlpha: 1, duration: CLOSE_CAPTION.duration, ease: CLOSE_CAPTION.ease }, CLOSE_CAPTION.at);
if (reducedMotion) this.tl.progress(1);
}
/** Copy the clicked image into the preview and activate its content group */
async fillContent(slide, index) {
const img = slide.querySelector(".gallery__img");
this.groups.forEach((group) =>
group.classList.toggle("active", Number(group.dataset.index) === index),
);
// Video slides move their own element into the preview in open()
if (img.tagName === "VIDEO") {
this.video = img;
this.videoHome = img.parentNode;
await mediaReady(img);
return;
}
this.previewImg.src = img.currentSrc || img.src;
this.previewImg.alt = img.alt;
try {
await this.previewImg.decode();
} catch {
// Rejects if the src is swapped mid-flight; the transition still runs
}
// Swap in the full-size photo once it is ready
const fullSrc = img.dataset.full;
if (fullSrc) {
const hi = new Image();
hi.src = fullSrc;
hi.decode()
.then(() => {
if (this.activeSlide === slide && this.state !== "closed") this.previewImg.src = fullSrc;
})
.catch(() => {});
}
}
parts() {
const slide = this.activeSlide;
return {
wrapper: slide.querySelector(".gallery__img-wrapper"),
caption: slide.querySelector("figcaption"),
others: this.slides.filter((s) => s !== slide),
};
}
/** Restore the DOM */
reset() {
const { wrapper } = this.parts();
delete wrapper.dataset.flipId;
// Return a moved video to its slide (it sits exactly over it by now)
if (this.video) {
this.videoHome.append(this.video);
gsap.set(this.video, { clearProps: "all" });
this.video = null;
this.videoHome = null;
}
this.previewMedia = this.previewImg;
this.split?.revert();
this.split = null;
gsap.set(this.content, { display: "none" });
gsap.set(this.preview, { clearProps: "all" });
gsap.set(this.previewImg, { clearProps: "all" });
gsap.set(wrapper, { clearProps: "all" });
this.activeSlide = null;
this.tl = null;
this.setState("closed");
this.onClose?.();
}
}
/* Boot ------------------------------------------------------------------ */
/** Resolves once an image has loaded or a video has its first frame (or failed) */
function mediaReady(el) {
return new Promise((resolve) => {
if (el.tagName === "VIDEO") {
if (el.readyState >= 2 || el.error) return resolve();
el.addEventListener("loadeddata", resolve, { once: true });
el.addEventListener("error", resolve, { once: true });
setTimeout(resolve, VIDEO_READY_TIMEOUT);
return;
}
if (el.complete) return resolve();
el.addEventListener("load", resolve, { once: true });
el.addEventListener("error", resolve, { once: true });
});
}
// Wait for every image, the videos' first frames and the fonts, so SplitText
// measures the final lines.
function whenReady() {
const media = [...document.querySelectorAll(".gallery__img")];
return Promise.all([...media.map(mediaReady), document.fonts.ready]);
}
/** Play videos only while on screen; keep the one in the preview playing and pause
the rest while a detail view is fully open. */
function createVideoPlayback(transition) {
const videos = gsap.utils.toArray(".gallery__video");
const inView = new Set();
const sync = () => {
videos.forEach((video) => {
const play =
!reducedMotion &&
(video === transition.video || (transition.state !== "open" && inView.has(video)));
if (play && video.paused) video.play().catch(() => {});
else if (!play && !video.paused) video.pause();
});
};
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => (e.isIntersecting ? inView.add(e.target) : inView.delete(e.target)));
sync();
},
{ rootMargin: VIDEO_VIEW_MARGIN },
);
videos.forEach((video) => io.observe(video));
sync();
return sync;
}
function init() {
// Image-to-content transition; hands the gallery back when it is done
const transition = new Transition({
onClose: () => slider.start(),
onChange: () => syncVideos?.(),
});
const syncVideos = createVideoPlayback(transition);
const reveal = new Reveal();
const slider = new Slider({
enabled: () => transition.state === "closed",
onToggle: (changes, immediate) => reveal.toggle(changes, immediate),
});
gsap.utils.toArray(".gallery__slide").forEach((slide, index) => {
slide.setAttribute("tabindex", "0");
slide.setAttribute("role", "button");
// Only give up the gallery if the transition is going to hand it back
const open = () => {
if (transition.state !== "closed") return;
slider.stop();
transition.open(slide, index);
};
slide.addEventListener("click", open);
slide.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
});
});
document.querySelector(".content__back").addEventListener("click", () => transition.close());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") transition.close();
});
}
whenReady().then(() => {
document.body.classList.add("is-ready");
init();
});
</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>
원본 구현 프롬프트
# Infinite Scroll Transition: an endless parallax gallery that morphs into a detail view
**Goal:** build a full-screen, endlessly looping vertical gallery of staggered photos, driven by wheel and touch with smooth inertia and per-slide parallax. Clicking a photo morphs it into a full-height preview on the left while its title and text fade in on the right; closing morphs it back into place.
## Visual behaviour
### The gallery
1. **Layout.** A fixed, full-viewport stage on a white background; the page itself never scrolls. A single centred column of slides (`flex-direction: column; align-items: center`), `12vh` apart, starting `14vh` from the top. Each slide is a portrait photo frame (aspect ratio 4:5, rounded 6px corners, `overflow: hidden`) with a small grey caption under it (12px, `#8E8E8E`, 6px gap).
2. **Stagger.** Every slide has its own width (between 10vw and 34vw) and its own sideways offset (`translateX` between −28vw and +28vw), alternating left and right, so the column zigzags irregularly. Example sequence (offset / width in vw): −24/20, 14/10, −18/23, 22/18, −26/25, 28/28, −20/16, 8/30, −12/15, 15/23, −24/24, 26/16, −16/34, 24/13, −28/19, 18/22, −10/27, 20/18, −22/19, 16/30, −18/17, 24/12, −26/23, 12/26. Use around 20–24 slides.
3. **Photo overscan.** The image inside each frame is `object-fit: cover` and scaled to `1.2`, so there is room for the open morph to zoom it to 1.
4. **Endless loop.** All slides move together along one closed loop: when a slide scrolls off the top, it re-enters from the bottom, with the same 12vh gap at the seam, so there is no visible start or end. Scrolling up works the same way in reverse.
5. **Input and inertia.** Wheel and touch (vertical or horizontal, whichever axis is larger) move a target position by the input's pixel delta (1px of wheel = 1px of travel). The displayed position eases to the target over **0.75s with `power3.out`** (cubic-bezier ≈ `0.215, 0.61, 0.355, 1`), restarted on every input event, which gives a smooth, gliding inertia. Prevent the default scroll.
6. **Parallax.** On top of the loop, each slide gets an extra vertical offset proportional to its distance from the top of the viewport: `offset = (speed − 1) × (slideTopWithoutOffset + slideHeight)`, recomputed every frame. Speeds cycle through `[1.3, 0.8, 1.15, 0.7, 1.25, 0.85]`, so neighbours drift at different rates while moving. The offset is zero at the point where a slide exits the top, so the loop seam stays clean.
7. **Reveal on entry.** A slide that is outside the viewport is invisible (image frame and caption at opacity 0). When it enters the viewport:
- the image frame fades in over **1s, `power2.out`**;
- the caption fades in character by character: **0.4s per character, linear, 0.025s apart, starting 0.2s after the image**;
- slides entering in the same frame are staggered **0.12s** apart, top to bottom.
When a slide leaves the viewport it is reset to invisible instantly, so the reveal replays every time it comes back. On load, the slides already on screen reveal this way.
### Open (click a slide)
8. Disable the gallery input and freeze the inertia where it is.
9. Copy the clicked slide's image into a fixed preview element: **full viewport height, aspect ratio 4:5, pinned to the top-left corner**. Show the matching title and description on the right (left padding `80vh + 1.5vw`; title starts `40vh` from the top; title `clamp(30px, 10vw, 72px)`, line height 1, letter spacing −0.03em; description `clamp(12px, 2.5vw, 18px)`, grey, max 50ch). A small uppercase "← Close" button sits top left of the text column.
10. Run together, from time 0:
- the other slides fade out: **0.5s, `power2.out`**;
- the clicked slide's caption fades out: **0.3s, `power2.out`**;
- **FLIP morph**: the preview animates from the thumbnail's exact position, size and 6px corner radius to its full-height place with square corners: **1.2s, `power4.inOut`** (cubic-bezier ≈ `0.86, 0, 0.07, 1`);
- the image inside the preview scales **1.2 → 1** over the same 1.2s, `power4.inOut`.
11. Starting **0.8s** after the morph starts, the close button, title and description fade in **character by character, line by line**: each line's characters fade from 0 to 1 over **1s, `power3.out`, 0.01s apart**, and each next line starts **0.06s** after the previous one.
12. Load a higher-resolution version of the image in the background and swap it into the preview once decoded.
### Close (button or Escape)
13. Run together, from time 0:
- the text lines fade out: **0.4s, `power1.out`, 0.04s apart**;
- the preview morphs back onto the thumbnail's current position and size (FLIP fit, corner radius back to 6px): **1s, `power3.inOut`**;
- the preview image scales **1 → 1.2** over the same 1s.
- At **0.5s**, the other slides fade back in (0.5s, `power2.out`); at **0.6s** the caption fades back in (0.4s, `power2.out`).
14. When done, hide the detail view, restore the thumbnail and give input back to the gallery.
15. Closing while the open animation is still running simply reverses it.
## Markup and structure
```html
<div class="stage"> <!-- position: fixed; inset: 0; overflow: hidden -->
<div class="gallery">
<figure class="gallery__slide" style="--stagger: -24vw; --img-w: 20vw;">
<div class="gallery__img-wrapper"><img class="gallery__img" src="…" data-full="…" alt="…"></div>
<figcaption>Linen Hour</figcaption>
</figure>
…
</div>
</div>
<div class="content"> <!-- position: fixed; inset: 0; display: none until opened -->
<figure class="content__preview-img"><img alt=""></figure>
<div class="content__group-list">
<button class="content__back">← Close</button>
<div class="content__group" data-index="0">
<div class="content__title">Linen Hour</div>
<div class="content__description">…</div>
</div>
… <!-- one group per slide; only the active one is shown -->
</div>
</div>
```
- Slides are focusable (`tabindex="0"`, `role="button"`); Enter or Space opens them.
- Wait for all images and the web font before building the loop and splitting text, so measurements are final.
## Libraries
- **GSAP 3** with **Observer** (wheel/touch input), **Flip** (the morph) and **SplitText** (characters and lines).
- The loop is GSAP's "seamless vertical loop" helper: a paused, repeating timeline where each slide has two tweens (move up and out past the top, then jump to below the last slide and move back to its start), with a constant speed of 100px per timeline second. The scroll position is the timeline's time, wrapped with `gsap.utils.wrap(0, duration)`. The inertia is a paused tween on a `{ time }` proxy whose target is updated and `restart()`ed on every input event; its `onUpdate` sets the loop's time and recomputes the parallax.
- Open: `Flip.getState(thumbnailFrame, { props: "borderRadius" })` before showing the detail view, then `Flip.from(state, { targets: preview, duration: 1.2, ease: "power4.inOut", absolute: true, props: "borderRadius" })`. Close: `Flip.fit(preview, thumbnailFrame, { duration: 1, ease: "power3.inOut", absolute: true, props: "borderRadius" })`.
- **Plain alternative:** build the loop by wrapping each slide's `translateY` with modulo arithmetic over the total column height, ease the position with a requestAnimationFrame lerp (about 0.08 per frame), and do the morph with a FLIP technique (measure both rects, animate `transform` + `border-radius` with the Web Animations API). Split text into spans yourself.
## Responsive and reduced motion
- Below 768px: slide widths × 1.6 and sideways offsets × 0.9, so slides stay legible.
- Portrait screens (aspect ratio ≤ 4/3): the preview spans the full width at the top (max 62vh tall) and the text sits below it, with smaller type.
- Rebuild the loop on resize (debounced 200ms), keeping the current position, without replaying reveals.
- `prefers-reduced-motion: reduce`: no inertia (input applies immediately), slides appear without fading, and open/close jump straight to their end states.
## Acceptance criteria
- Scrolling or swiping moves the gallery smoothly with a soft 0.75s glide, endlessly in both directions, with no visible seam.
- Slides drift at visibly different speeds while moving and settle into a scattered zigzag.
- Each slide fades in (image, then caption letter by letter) every time it enters the viewport.
- Clicking a slide morphs it from its exact position into a full-height image on the left in 1.2s, its corners going from rounded to square and its photo zooming out from 1.2 to 1, while every other slide fades away and the text writes itself in line by line.
- Close and Escape morph it back exactly onto its thumbnail, fade the gallery back in and restore scrolling.
- The gallery doesn't respond to wheel or touch while the detail view is open.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: db899048360e4758e4817dd42f72e1939e80f6d2e8e8e7b2dfbb5d77210e904f