Codrops 원본
Building an Infinite GSAP Scroll Gallery with Parallax and Flip Transitions
스크롤 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
index.html
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Infinite Scroll Gallery with Page Transitions using GSAP</title>
<link rel="stylesheet" href="https://use.typekit.net/hrf4hvy.css">
<link rel="stylesheet" type="text/css" href="src/style.css" />
<link rel="icon" type="image/svg+xml" href="https://tympanus.net/favicon/favicon.svg" />
<link rel="shortcut icon" href="https://tympanus.net/favicon/favicon.ico" />
<script>document.documentElement.className = 'js';</script>
<script src="//tympanus.net/codrops/adpacks/analytics.js"></script>
</head>
<body class="loading">
<main>
<header class="frame">
<h1 class="frame__title">Infinite Scroll Gallery with Page Transitions using GSAP</h1>
<nav class="frame__links">
<a href="https://tympanus.net/codrops/?p=118084">Tutorial</a>
<a href="https://tympanus.net/codrops/hub/">All demos</a>
<a href="https://github.com/surya-aditya/codrops-infinite-scroll-and-content-transition">GitHub</a>
</nav>
<nav class="frame__tags">
<a href="https://tympanus.net/codrops/hub/tag/scroll/">#scroll</a>
<a href="https://tympanus.net/codrops/hub/tag/gsap/">#gsap</a>
<a href="https://tympanus.net/codrops/hub/tag/page-transition/">#page-transition</a>
<a href="https://tympanus.net/codrops/hub/tag/infinite/">#infinite</a>
</nav>
</header>
<div class="gallery">
<figure class="gallery__slide" style="--stagger: -24vw; --img-w: 20vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/1.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Inherited Light</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 14vw; --img-w: 10vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/2.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Red Earth Theory</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -18vw; --img-w: 23vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/3.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Soft Sovereignty</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 22vw; --img-w: 18vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/4.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Gathering Weather</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -26vw; --img-w: 25vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/5.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Worn Horizon</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 28vw; --img-w: 28vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/6.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>The Fifth Fire</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -20vw; --img-w: 16vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/7.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Ancestor Season</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 8vw; --img-w: 30vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/8.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>A Field Listening</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -12vw; --img-w: 15vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/9.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Language of Dust</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 15vw; --img-w: 23vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/10.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Between Sweetgrass</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -24vw; --img-w: 24vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/11.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Open Country</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 26vw; --img-w: 16vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/12.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>The Living Ridge</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -16vw; --img-w: 34vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/13.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Future Ceremony</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 24vw; --img-w: 13vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/14.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Holding the West</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -28vw; --img-w: 19vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/15.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>The Quiet Treaty</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 18vw; --img-w: 22vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/16.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Sky Without Borders</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -10vw; --img-w: 27vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/17.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Native Modern</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 20vw; --img-w: 18vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/18.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Stone Kinship</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -22vw; --img-w: 19vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/19.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Winter Holds</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 16vw; --img-w: 30vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/20.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>The Seventh Path</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -18vw; --img-w: 17vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/21.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Listening Ground</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 24vw; --img-w: 12vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/22.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Made of Distance</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: -26vw; --img-w: 23vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/23.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Names for Rain</figcaption>
</figure>
<figure class="gallery__slide" style="--stagger: 12vw; --img-w: 26vw;">
<div class="gallery__img-wrapper">
<img
class="gallery__img"
src="./assets/24.webp"
alt="Some image"
loading="eager" />
</div>
<figcaption>Returning North</figcaption>
</figure>
</div>
<div class="content">
<div class="content-wrapper">
<figure class="content__preview-img">
<img alt="" />
</figure>
<div class="content__group-list">
<button class="content__back" type="button">← back [ESC]</button>
<div class="content__group" data-index="0">
<div class="content__title">Inherited Light</div>
<div class="content__description">
Before fences divided the prairie, horses transformed life across many Plains Nations, becoming companions in travel, hunting, and ceremony. The opening image returns to that enduring relationship between movement, land, and identity.
</div>
</div>
<div class="content__group" data-index="1">
<div class="content__title">Red Earth Theory</div>
<div class="content__description">
Every stitch recalls a tradition older than the runway. Indigenous beadwork and embroidery have long carried family histories, regional identities, and stories passed from one generation to the next.
</div>
</div>
<div class="content__group" data-index="2">
<div class="content__title">Soft Sovereignty</div>
<div class="content__description">
Contemporary Indigenous fashion refuses to separate heritage from innovation. Here, bold modern forms coexist with an older understanding: sovereignty begins with the freedom to define one's own image.
</div>
</div>
<div class="content__group" data-index="3">
<div class="content__title">Gathering Weather</div>
<div class="content__description">
Garments shaped by movement echo a life lived close to changing skies. Across generations, clothing has adapted to the landscape rather than asking the landscape to adapt in return.
</div>
</div>
<div class="content__group" data-index="4">
<div class="content__title">Worn Horizon</div>
<div class="content__description">
Wildflowers bloom across lands that remember both abundance and loss. Beauty persists quietly, much like the cultures that continue to flourish despite centuries of attempted erasure.
</div>
</div>
<div class="content__group" data-index="5">
<div class="content__title">The Fifth Fire</div>
<div class="content__description">
Many Indigenous teachings describe every generation as a keeper of what comes next. Even the simplest object becomes a reminder that renewal begins with care rather than spectacle.
</div>
</div>
<div class="content__group" data-index="6">
<div class="content__title">Ancestor Season</div>
<div class="content__description">
Community has always embraced many voices rather than one. Individual expression sits comfortably beside collective identity, each strengthening the other instead of competing with it.
</div>
</div>
<div class="content__group" data-index="7">
<div class="content__title">A Field Listening</div>
<div class="content__description">
Red recalls ochre, earth, trade cloth, and ceremony without belonging to any single tradition. Colour becomes memory carried across generations instead of preserved behind glass.
</div>
</div>
<div class="content__group" data-index="8">
<div class="content__title">Language of Dust</div>
<div class="content__description">
More than a hundred Indigenous languages are spoken across North America today. Each surviving language offers another way of understanding landscape, kinship, and the passing of seasons.
</div>
</div>
<div class="content__group" data-index="9">
<div class="content__title">Between Sweetgrass</div>
<div class="content__description">
Sweetgrass has long been gathered across many Nations as a symbol of kindness, gratitude, and connection. Its gentle presence reminds us that strength often arrives without raising its voice.
</div>
</div>
<div class="content__group" data-index="10">
<div class="content__title">Open Country</div>
<div class="content__description">
Contemporary Indigenous artists increasingly reclaim portraiture on their own terms. The gaze no longer documents a disappearing people; it reflects a living culture shaping its own future.
</div>
</div>
<div class="content__group" data-index="11">
<div class="content__title">The Living Ridge</div>
<div class="content__description">
The horizon remains one of the oldest teachers. Across countless Nations, stories, navigation, and ceremony begin not inside buildings but in relationship with the land itself.
</div>
</div>
<div class="content__group" data-index="12">
<div class="content__title">Future Ceremony</div>
<div class="content__description">
For generations, boarding schools attempted to erase Indigenous identity by replacing language, clothing, and tradition. Today's designers answer not with nostalgia, but with confident reinvention.
</div>
</div>
<div class="content__group" data-index="13">
<div class="content__title">Holding the West</div>
<div class="content__description">
Monochrome becomes an act of restraint. Contemporary Native fashion often values material, silhouette, and story over spectacle, allowing craftsmanship to speak before symbolism.
</div>
</div>
<div class="content__group" data-index="14">
<div class="content__title">The Quiet Treaty</div>
<div class="content__description">
History remembers broken treaties; communities remember those who endured them. This portrait looks forward without forgetting the promises that continue to shape Indigenous life today.
</div>
</div>
<div class="content__group" data-index="15">
<div class="content__title">Sky Without Borders</div>
<div class="content__description">
The sky has never recognized the borders drawn across maps. For many Indigenous worldviews, belonging begins with relationship—to place, to people, and to all living things.
</div>
</div>
<div class="content__group" data-index="16">
<div class="content__title">Native Modern</div>
<div class="content__description">
Indigenous fashion is among the fastest-evolving voices in contemporary design, proving that tradition is not the opposite of modernity. It is the foundation from which modernity grows.
</div>
</div>
<div class="content__group" data-index="17">
<div class="content__title">Stone Kinship</div>
<div class="content__description">
Stone remembers what people often forget. Across many Indigenous traditions, the landscape is not scenery but a relative, carrying stories older than any written archive.
</div>
</div>
<div class="content__group" data-index="18">
<div class="content__title">Winter Holds</div>
<div class="content__description">
Survival has always been a form of beauty. Winter demanded ingenuity, patience, and deep communal knowledge—qualities still reflected in contemporary Indigenous design.
</div>
</div>
<div class="content__group" data-index="19">
<div class="content__title">The Seventh Path</div>
<div class="content__description">
Many Indigenous teachings ask each generation to consider those yet to come. Fashion, too, becomes an inheritance when made with responsibility rather than excess.
</div>
</div>
<div class="content__group" data-index="20">
<div class="content__title">Listening Ground</div>
<div class="content__description">
To walk the land is also to listen. Traditional ecological knowledge has shaped sustainable relationships with forests, rivers, and plains for countless generations.
</div>
</div>
<div class="content__group" data-index="21">
<div class="content__title">Made of Distance</div>
<div class="content__description">
Forced removals separated many Nations from their ancestral territories, yet identity endured. Home has always been carried in language, family, ceremony, and memory as much as geography.
</div>
</div>
<div class="content__group" data-index="22">
<div class="content__title">Names for Rain</div>
<div class="content__description">
Indigenous languages often describe weather with remarkable precision, reflecting generations spent observing subtle changes in the natural world. Every season has its own vocabulary.
</div>
</div>
<div class="content__group" data-index="23">
<div class="content__title">Returning North</div>
<div class="content__description">
Across North America, a new generation of Indigenous artists and designers is reclaiming narrative through fashion, proving that culture is strongest when it continues to evolve instead of standing still.
</div>
</div>
</div>
</div>
</div>
</main>
<script type="module" src="src/index.js"></script>
<script src="https://tympanus.net/codrops/adpacks/cda_sponsor.js"></script>
</body>
</html>src/Reveal.js
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
/** Reveal each slide (image + caption) as it enters the viewport */
export default class Reveal {
constructor() {
// Reveal targets per slide
this.items = new Map();
gsap.utils.toArray(".gallery__slide").forEach((slide) => {
const wrapper = slide.querySelector(".gallery__img-wrapper");
// Split the caption into characters
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((change) => change.visible)
.sort((a, b) => a.top - b.top)
.forEach((change, i) => this.show(change.el, i * 0.12, immediate));
changes.filter((change) => !change.visible).forEach((change) => this.hide(change.el));
}
/** Fade the image in, then fade the caption in character by character */
show(slide, delay, immediate = false) {
const { wrapper, chars } = this.items.get(slide);
// The viewport moved around the slide, so there is no entrance to play
if (immediate) {
gsap.set([wrapper, ...chars], { autoAlpha: 1, overwrite: true });
return;
}
gsap.to(wrapper, {
autoAlpha: 1,
duration: 1,
ease: "power2.out",
delay,
overwrite: true,
});
gsap.to(chars, {
autoAlpha: 1,
duration: 0.4,
ease: "none",
stagger: 0.025,
delay: delay + 0.2,
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 });
}
}
함께 쓰는 파일 8개 보기
src/Slider.js
import { gsap } from "gsap";
import { Observer } from "gsap/Observer";
import verticalLoop from "./verticalLoop.js";
gsap.registerPlugin(Observer);
/** Infinite slider driven by wheel/touch scrolling */
export default class Slider {
constructor({ enabled = () => true, onToggle } = {}) {
this.enabled = enabled;
// Called with the slides that just entered or left the viewport
this.onToggle = onToggle;
this.createLoop();
this.createParallax();
this.createScrub();
this.createObserver();
this.resize();
}
/** Build vertical loop from the gallery slides */
createLoop() {
const gallery = document.querySelector(".gallery");
// The gap between slides doubles as the loop's bottom padding
const gap = parseFloat(getComputedStyle(gallery).rowGap);
// A paused one lap timeline; its playhead is our 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());
}
/** Give each slide its own travel speed for a sense of depth */
createParallax() {
// Speed multipliers: > 1 faster, < 1 slower
const speeds = [1.3, 0.8, 1.15, 0.7, 1.25, 0.85];
this.parallax = gsap.utils.toArray(".gallery__slide").map((slide, i) => ({
el: slide,
factor: speeds[i % 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();
// The slide's position without our offset
const loopTop = rect.top - item.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 });
// Check visibility from the final position
const top = loopTop + item.offset;
const visible = top < window.innerHeight && top + rect.height > 0;
// Collect the slides that entered or left
if (visible !== item.visible) {
item.visible = visible;
changes.push({ el: item.el, visible, top });
}
});
if (changes.length) this.onToggle?.(changes, immediate);
}
/** Create the eased playhead that smooths scroll input into loop time */
createScrub() {
// Proxy object standing in for the scroll position
this.playhead = { time: 0 };
// Eases the playhead toward the latest scroll target
this.scrub = gsap.to(this.playhead, {
time: 0,
duration: 0.75,
ease: "power3.out",
paused: true,
onUpdate: () => {
this.loop.time(this.wrap(this.playhead.time));
this.applyParallax();
},
});
}
/** Listen for wheel and touch input */
createObserver() {
this.observer = Observer.create({
target: window,
type: "wheel,touch",
preventDefault: true,
onChange: (self) => {
this.scroll(self);
},
});
}
/** Rebuild the loop when the viewport size settles */
resize() {
let id;
window.addEventListener("resize", () => {
clearTimeout(id);
id = setTimeout(() => {
this.rebuild();
}, 200);
});
}
/** 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;
// Slides travel 100px per playhead second, so px / 100 gives time
this.scrub.vars.time += delta / 100;
this.scrub.invalidate().restart();
}
/** Freeze the inertia in place so a transition can take over */
freeze() {
this.scrub.pause();
// Sync the target to where we stopped, so the next scroll ramps from here
this.scrub.vars.time = this.playhead.time;
this.scrub.invalidate();
}
/** Hand the gallery over to a transition */
stop() {
this.observer.disable();
this.freeze();
}
/** Take the gallery back once a transition has finished with it */
start() {
this.observer.enable();
}
/** Re-measure and rebuild the loop, preserving the current position */
rebuild() {
// 0 to 1 progress
const progress = this.loop.progress();
// Drop the old timeline and its transforms; the stagger lives in the CSS
this.freeze();
this.loop.kill();
gsap.set(".gallery__slide", { clearProps: "transform" });
// Re-measure the new viewport size and restore the position
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);
// Re-sync the scrub to the rebuilt loop's timing
this.playhead.time = this.loop.time();
this.scrub.vars.time = this.playhead.time;
this.scrub.invalidate();
}
}
src/Transition.js
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(Flip, SplitText);
/** Image-to-content transition */
export default class Transition {
constructor({ onClose } = {}) {
// Called once the gallery is free again
this.onClose = onClose;
// Fixed overlay holding the preview image and the content column
this.content = document.querySelector(".content");
// Figure the clicked image morphs into
this.preview = document.querySelector(".content__preview-img");
this.previewImg = this.preview.querySelector("img");
// One content group per slide, matched by data-index
this.groups = gsap.utils.toArray(".content__group");
this.slides = gsap.utils.toArray(".gallery__slide");
// Current active slide
this.activeSlide = null;
// Transition timeline
this.tl = null;
// SplitText instance for the active content
this.split = null;
// Transition state = closed | opening | open | closing
this.state = "closed";
// The preview's Flip identity
this.preview.dataset.flipId = "preview";
}
/** Animate to preview */
async open(slide, index) {
if (this.state !== "closed") return;
this.state = "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);
// 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 });
// Split the text into lines and characters
this.split = new SplitText(".content__back, .content__group.active > *", {
type: "lines,chars",
charsClass: "char",
});
this.tl = gsap
.timeline({
onComplete: () => (this.state = "open"),
// Fires when a cancelled open finishes rewinding
onReverseComplete: () => this.reset(),
})
// Fade out the other slides and the caption
.to(others, { autoAlpha: 0, duration: 0.5, ease: "power2.out" }, 0)
.to(caption, { autoAlpha: 0, duration: 0.3, ease: "power2.out" }, 0)
// Morph the selected image
.add(
Flip.from(state, {
targets: this.preview,
duration: 1.2,
ease: "power4.inOut",
absolute: true,
}),
0,
)
.to(this.previewImg, { scale: 1, duration: 1.2, ease: "power4.inOut" }, 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,
duration: 1,
ease: "power3.out",
stagger: 0.01,
},
// Preview delay + line index * stagger delay
0.8 + i * 0.06,
);
});
}
/** Animate the preview back into its slide */
close() {
// Close mid-animation
if (this.state === "opening") {
this.state = "closing";
// Still waiting on the decode, so there is no timeline to rewind
if (!this.tl) {
this.reset();
return;
}
this.tl.reverse();
return;
}
if (this.state !== "open") return;
this.state = "closing";
const { wrapper, caption, others } = this.parts();
this.tl = gsap
.timeline({ onComplete: () => this.reset() })
// Fade the text out line by line
.to(this.split.lines, { autoAlpha: 0, duration: 0.4, stagger: 0.04, ease: "power1.out" }, 0)
// Morph the preview back into the thumbnail's bounds
.add(
Flip.fit(this.preview, wrapper, {
duration: 1,
ease: "power3.inOut",
absolute: true,
}),
0,
)
.to(this.previewImg, { scale: 1.2, duration: 1, ease: "power3.inOut" }, 0)
// Fade the other slides and caption back in
.to(others, { autoAlpha: 1, duration: 0.5, ease: "power2.out" }, 0.5)
.to(caption, { autoAlpha: 1, duration: 0.4, ease: "power2.out" }, 0.6);
}
/** Copy the clicked image into the preview and activate its content group */
async fillContent(slide, index) {
const img = slide.querySelector(".gallery__img");
// Same src as the thumbnail
this.previewImg.src = img.src;
this.previewImg.alt = img.alt;
this.groups.forEach((group) =>
group.classList.toggle("active", Number(group.dataset.index) === index),
);
// Undecoded, the preview paints blank for the first frames of the morph
try {
await this.previewImg.decode();
} catch {
// Rejects if the src is swapped mid-flight; the transition still runs
}
}
/** Collect the pieces of the active slide the animations need */
parts() {
const slide = this.activeSlide;
return {
// Flip target
wrapper: slide.querySelector(".gallery__img-wrapper"),
// Title under the image
caption: slide.querySelector("figcaption"),
// Other slide items
others: this.slides.filter((s) => s !== slide),
};
}
/** Restore the DOM */
reset() {
const { wrapper } = this.parts();
// The thumbnail only carries the Flip id while its slide is expanded
delete wrapper.dataset.flipId;
// Revert split
this.split?.revert();
this.split = null;
// Hide the overlay and clear style the animations left
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.state = "closed";
this.onClose?.();
}
}
src/index.js
import imagesLoaded from "imagesloaded";
import Reveal from "./Reveal.js";
import Slider from "./Slider.js";
import Transition from "./Transition.js";
imagesLoaded(document.body, () => {
document.body.classList.remove("loading");
init();
});
function init() {
// Image-to-content transition; hands the gallery back when it is done
const transition = new Transition({ onClose: () => slider.start() });
// Slide reveals
const reveal = new Reveal();
// Infinite slider
const slider = new Slider({
enabled: () => transition.state === "closed",
onToggle: (changes) => reveal.toggle(changes),
});
// Gallery slides
const slides = [...document.querySelectorAll(".gallery__slide")];
slides.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();
});
}
src/style.css
*,
*::after,
*::before {
box-sizing: border-box;
}
:root {
font-size: 12px;
--color-text: #9b9b9b;
--color-bg: #eaeeed;
--color-link: #9b9b9b;
--color-link-hover: #000000;
--color-desc: #9b9b9b;
--page-padding: 1.5rem;
}
/* The slider drives the gallery, so the document itself never scrolls */
html,
body {
height: 100%;
overflow: hidden;
}
body {
margin: 0;
color: var(--color-text);
background-color: var(--color-bg);
font-family: "host-grotesk", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
touch-action: none;
}
@media (scripting: enabled) {
.loading {
&::before,
&::after {
content: "";
position: fixed;
z-index: 10000;
}
&::before {
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-bg);
}
&::after {
top: 50%;
left: 50%;
width: 100px;
height: 1px;
margin: 0 0 0 -50px;
background: var(--color-link);
animation: loaderAnim 1.5s ease-in-out infinite alternate forwards;
}
}
}
@keyframes loaderAnim {
0% {
transform: scaleX(0);
transform-origin: 0% 50%;
}
50% {
transform: scaleX(1);
transform-origin: 0% 50%;
}
50.1% {
transform: scaleX(1);
transform-origin: 100% 50%;
}
100% {
transform: scaleX(0);
transform-origin: 100% 50%;
}
}
a {
text-decoration: none;
color: var(--color-link);
outline: none;
cursor: pointer;
&:hover {
text-decoration: underline;
color: var(--color-link-hover);
}
&:focus {
outline: none;
background: lightgrey;
&:not(:focus-visible) {
background: transparent;
}
&:focus-visible {
outline: 2px solid red;
background: transparent;
}
}
}
.frame {
padding: var(--page-padding);
display: grid;
z-index: 100;
position: relative;
gap: 1rem;
pointer-events: none;
justify-items: start;
grid-template-columns: 100%;
grid-template-areas:
"title"
"links"
"tags"
"sponsor";
#cdawrap {
justify-self: start;
grid-area: sponsor;
}
a,
button {
pointer-events: auto;
}
.frame__title {
grid-area: title;
font-size: inherit;
font-weight: inherit;
margin: 0;
}
.frame__tags {
grid-area: tags;
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.frame__links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.frame__links {
grid-area: links;
}
@media screen and (min-width: 53em) {
height: 100%;
position: fixed;
top: 0;
left: 0;
width: 100%;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto auto;
align-content: space-between;
grid-template-areas:
"title ... links"
"tags tags sponsor";
.frame__tags {
align-self: end;
}
.frame__links,
#cdawrap {
justify-self: end;
text-align: right;
max-width: 300px;
}
}
}
/* Gallery */
.gallery {
display: flex;
flex-direction: column;
align-items: center;
gap: 12vh;
width: 100%;
padding-top: 14vh;
}
.gallery__slide {
flex-shrink: 0;
transform: translateX(var(--stagger, 0vw));
cursor: pointer;
outline: none;
padding: 0;
}
.gallery__slide:nth-child(1) {
--stagger: -24vw;
--img-w: 290px;
}
.gallery__slide:nth-child(2) {
--stagger: 14vw;
--img-w: 330px;
}
.gallery__slide:nth-child(3) {
--stagger: -18vw;
--img-w: 270px;
}
.gallery__slide:nth-child(4) {
--stagger: 22vw;
--img-w: 450px;
}
.gallery__slide:nth-child(5) {
--stagger: -26vw;
--img-w: 300px;
}
.gallery__slide:nth-child(6) {
--stagger: 28vw;
--img-w: 150px;
}
.gallery__slide:nth-child(7) {
--stagger: -20vw;
--img-w: 230px;
}
.gallery__slide:nth-child(8) {
--stagger: 8vw;
--img-w: 460px;
}
.gallery__slide:nth-child(9) {
--stagger: -12vw;
--img-w: 270px;
}
.gallery__slide:nth-child(10) {
--stagger: 15vw;
--img-w: 250px;
}
.gallery__slide:nth-child(11) {
--stagger: -24vw;
--img-w: 390px;
}
.gallery__slide:nth-child(12) {
--stagger: 26vw;
--img-w: 160px;
}
.gallery__slide:nth-child(13) {
--stagger: -16vw;
--img-w: 210px;
}
.gallery__slide:nth-child(14) {
--stagger: 24vw;
--img-w: 280px;
}
.gallery__slide:nth-child(15) {
--stagger: -28vw;
--img-w: 175px;
}
.gallery__slide:nth-child(16) {
--stagger: 18vw;
--img-w: 240px;
}
.gallery__slide:nth-child(17) {
--stagger: -10vw;
--img-w: 310px;
}
.gallery__slide figcaption {
display: block;
margin-top: 0.5rem;
color: var(--color-desc);
}
.gallery__img-wrapper {
position: relative;
margin: 0;
width: var(--img-w, 200px);
height: auto;
aspect-ratio: 0.8;
overflow: hidden;
pointer-events: none;
user-select: none;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.2);
}
}
/**
* Content: Fixed popup overlay
*/
.content {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
}
/**
* Content: Clicked image lands here
*/
.content__preview-img {
position: fixed;
margin: 0;
top: 0;
left: 0;
height: 100vh;
aspect-ratio: 0.8;
overflow: hidden;
img {
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) var(--page-padding);
padding-block: var(--page-padding);
}
.content__back {
font-weight: inherit;
font-size: inherit;
align-self: flex-start;
border: none;
background: none;
color: #111;
cursor: pointer;
padding: 0;
line-height: inherit;
}
.content__group {
display: none;
}
.content__group.active {
display: block;
}
.content__title {
justify-self: end;
font-family: "ivyora-display", serif;
font-weight: 400;
font-size: clamp(2.5rem, 10vw, 6rem);
line-height: 1;
margin-bottom: 3rem;
margin-top: 40vh;
color: #141414;
}
.content__title:focus {
outline: none;
}
.content__description {
justify-self: end;
margin-right: 10vw;
max-width: 50ch;
font-size: clamp(1rem, 2.5vw, 1.5rem);
}
src/verticalLoop.js
import { gsap } from "gsap";
/** Animate items along the y-axis in a loop */
export default function verticalLoop(items, config) {
items = gsap.utils.toArray(items);
config = config || {};
// Endless timeline
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;
// Measure from the top so any top padding is travelled before a wrap
const startY = 0;
// Height of each item, in px
const heights = [];
// Starting y of each item, relative to its height
const yPercents = [];
// Travel speed: speed 1 = 100px per second
const pixelsPerSecond = (config.speed || 1) * 100;
// Round yPercent values so items land on whole pixels
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];
// Current y offset in px
const curY = (yPercents[i] / 100) * heights[i];
// Distance to the container's top edge
const distanceToStart = item.offsetTop + curY - startY;
// Distance until fully off-screen top
const distanceToLoop = distanceToStart + heights[i] * gsap.getProperty(item, "scaleY");
// Travel up until fully off-screen
tl.to(
item,
{
yPercent: snap(((curY - distanceToLoop) / heights[i]) * 100),
duration: distanceToLoop / pixelsPerSecond,
},
0,
).fromTo(
// Then re-enter from the bottom end of the container
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;
}
Original author attribution실행 안내·자료
Building an Infinite GSAP Scroll Gallery with Parallax and Flip Transitions
Original author: Surya Aditya
Published by Codrops. Copyright (c) 2026 Codrops.
License: MIT, pursuant to the publisher's downloadable-demo grant.
Keep CODROPS-MIT-2026.txt and the original project notices with copies.
Third-party media exceptions and credits are recorded separately in the source inventory.
Media credits and license evidence실행 안내·자료
README.md
## Credits
- Images generated with Midjourney.
## License
[MIT](LICENSE)
Bundled dependency licenses실행 안내·자료
GSAP standard license snapshot
Source: https://gsap.com/community/standard-license/
Retrieved: 2026-09-22T04:22:49.089Z
Original distribution copyright and version headers remain in the runtime code.
Standard "No Charge" GSAP License
I. DEFINITIONS
“GSAP License” means the terms and conditions of this GSAP Software License Agreement.
"GSAP Products" means any Software made available at gsap.com (https://gsap.com) or any successor sites, including but not limited to the GSAP animation library and related plugins, tools, or extensions.
"Permitted Uses" means the implementation and/or use of GSAP Products on any website, web application, or digital interface by any person or entity (which may include, for clarity, those of companies that compete with Webflow in other areas of business).
"Prohibited Uses" means any implementation and/or use of GSAP Products in tools that allow users to build visual animations without code that encourages, induces, or materially assists in creating a solution that competes with Webflow’s visual animation building capabilities.
"Competitive Products" means any software, tool, or service that enables users to create, edit, or manage animations through a visual interface or builder similar to Webflow (https://webflow.com).
II. GRANT OF LICENSE
Subject to the terms and conditions of this GSAP License, Webflow grants you a non-exclusive, worldwide license to use, reproduce, display, and implement GSAP Products solely for Permitted Uses.
III. RESTRICTIONS
You may not:
Use any GSAP Products for any Prohibited Uses without prior written consent;
Reverse engineer any GSAP Products for the purpose of creating Competitive Products;
Remove or alter any proprietary notices or branding from GSAP Products.
IV. OWNERSHIP AND INTELLECTUAL PROPERTY
All intellectual property rights in GSAP Products, including but not limited to copyright, patents, trademarks, and trade secrets, remain the exclusive property of Webflow. This GSAP License does not transfer any ownership rights in GSAP Products to you.
V. TERMINATION
Webflow may terminate this GSAP License and revoke your access in its discretion if you fail to comply with any of these terms and conditions. Upon termination, you must cease all use of GSAP Products and destroy all copies in your possession.
VI. MISCELLANEOUS PROVISIONS
General: This GSAP License is incorporated into and subject to Webflow’s Terms of Service available here (https://webflow.com/legal/terms) ("Terms of Service"). In the event of any conflict or inconsistency between this GSAP License and the Terms of Service, the terms of this GSAP License shall govern in relation to your use of any GSAP Products.
Amendments: Webflow reserves the right to update or modify this GSAP License at any time by posting the revised terms on this website, provided that any such updates or modifications shall not result in any material degradation to the security, integrity, or functionality of any GSAP Products. You understand and agree that your continued use of any GSAP Products after such revisions to this GSAP License constitutes your acceptance of this GSAP License as revised. If you do not accept the revised GSAP License, you are prohibited from using versions of the GSAP Products released after the effective date of the revised GSAP License (as well as any updates made to previous versions). Notwithstanding, you may continue using previous versions of GSAP Products under the applicable terms licensed to you prior to the effective date of the revised GSAP License (for clarity, excluding any updates made thereto).
No Waiver: Failure of Webflow to enforce any provision of this GSAP License shall not constitute a waiver of future enforcement of that or any other provision.
FAQ
Is it acceptable for AI tools like ChatGPT, Cursor, Lovable, Webstudio, etc. to generate GSAP code?
Absolutely! AI-generated code is not a "Prohibited Use".
What if a WordPress plugin or theme or other niche tool allows users to create GSAP-driven effects through a visual interface? Is that prohibited?
We want to encourage developers to build on top of GSAP, including visual tools that don't directly compete with Webflow's rich animation-building capabilities. If you are not sure if your product might be considered a "Prohibited Use", feel free to contact us (https://gsap.com/contact) so we can talk through it!
Can I really use GSAP in commercial projects without paying anything?
Yes, really! Commercial usage is covered under the standard license. All of GSAP including the plugins that were formerly "members-only" like SplitText (https://gsap.com/docs/v3/Plugins/SplitText/) and MorphSVG (https://gsap.com/docs/v3/Plugins/MorphSVGPlugin) can be used in commercial projects at no charge. Enjoy! 💚
Effective date: April 30, 2025
Last modified date: May 30, 2025
Copyright (©) 2025, Webflow
ev-emitter@2.1.2 — LICENSE.md
Copyright (c) 2016-2021 [David DeSandro](https://desandro.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
imagesloaded@5.0.0 — LICENSE.md
Copyright (c) 2011-2022 [David DeSandro](https://desandro.com) and [contributors](https://github.com/desandro/imagesloaded/graphs/contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BEHIND THE EXAMPLE
이 장면을 만드는 원리
이미지 decode 대기 중에도 상세 열기 취소
반복 입력에서 현재 의도와 전환의 종료·취소 책임을 명시합니다.
- 이 예제에서는
- Transition.open은 fillContent의 decode를 기다린 뒤 opening 상태를 다시 확인합니다. close는 타임라인이 없으면 reset하고, 이미 재생 중이면 역재생한 뒤 정리합니다.
코드와 함께 확인하기
코드에서 찾기
async open(slide, index)Transition.js비동기 이미지 준비와 애니메이션 진행을 상태로 구분해 늦은 완료를 거릅니다.
직접 해보기
이미지 decode를 지연시킨 상태에서 열기·닫기·다른 항목 열기를 연속 실행합니다.
살펴볼 변화닫은 상세가 뒤늦게 다시 열리거나 새 항목 상태가 이전 decode 완료에 덮이지 않는지 확인해야 합니다.
