Codrops 원본
Building Persistent Page Transitions with WebGPU and Vanilla JavaScript
배경 · 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>Page Transitions with WebGPU and VanillaJS</title>
<meta name="description" content="We build a SPA with VanillaJS and pair it with WebGPU for interactive page transitions." />
<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" />
<link rel="stylesheet" type="text/css" href="src/global.css" />
<script>
document.documentElement.className = 'js';
</script>
</head>
<body>
<div id="preloader">
<span class="preloader-count">0%</span>
</div>
<nav id="nav">
<a href="/" data-link data-nav-key="main">Selected</a>
<a href="/index" data-link data-nav-key="index">Index</a>
</nav>
<main id="app"></main>
<div id="cursor"></div>
<footer id="footer">
<a href="https://benpaine.com" target="_blank" rel="noopener noreferrer">BNPNE</a>
<div>
<a href="https://tympanus.net/codrops/?p=116944" target="_blank" rel="noopener noreferrer">Tutorial</a>
<a href="https://github.com/bnpne/page-transitions-with-webgpu-vanilla-js" target="_blank" rel="noopener noreferrer">Source</a>
<a href="https://tympanus.net/codrops/hub" target="_blank" rel="noopener noreferrer">All demos</a>
</div>
</footer>
<script type="module" src="src/index.js"></script>
</body>
</html>src/carousel.js
const LERP = 0.1;
const GAP_PX = 48;
export class Carousel {
constructor(rootEl) {
this.slots = Array.from(rootEl.querySelectorAll('.slot'));
this.scrollX = 0;
this.targetScrollX = 0;
this.cellW = 0;
this.stepX = 0;
this.periodX = 0;
this.velocity = 0; // signed px/frame the carousel moved this tick
this.onWheel = this.onWheel.bind(this);
}
prepare() {
this.measure();
void this.slots[0]?.offsetHeight;
this.applyTransforms();
}
start() {
this.prepare();
window.addEventListener('wheel', this.onWheel, {
capture: true,
passive: false,
});
}
stop() {
window.removeEventListener('wheel', this.onWheel, { capture: true });
}
measure() {
this.cellW = this.slots[0]?.offsetWidth ?? 0;
this.stepX = this.cellW + GAP_PX;
this.periodX = this.slots.length * this.stepX;
}
onWheel(e) {
e.preventDefault();
e.stopImmediatePropagation();
const delta =
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
this.targetScrollX += delta;
}
applyTransforms() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const half = this.periodX / 2;
const c = Math.floor(this.slots.length / 2);
for (let i = 0; i < this.slots.length; i++) {
let relX = (i - c) * this.stepX - this.scrollX;
relX = ((relX % this.periodX) + this.periodX) % this.periodX;
if (relX >= half) relX -= this.periodX;
const cellH = this.slots[i].offsetHeight;
const x = vw / 2 + relX - this.cellW / 2;
const y = vh / 2 - cellH / 2;
this.slots[i].style.transform = `translate(${x}px, ${y}px)`;
}
}
tick() {
const prev = this.scrollX;
this.scrollX += (this.targetScrollX - this.scrollX) * LERP;
this.velocity = this.scrollX - prev;
this.applyTransforms();
}
}
함께 쓰는 파일 20개 보기
src/controller.js
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { home } from "./pages/home.js";
import { inner } from "./pages/inner.js";
import { index as indexPage, IndexFloat } from "./pages/index-page.js";
import { Carousel } from "./carousel.js";
import { MAIN_COUNT, SATELLITES_PER_IMAGE, mainIdx, satIdx } from "./gpu.js";
import { IndexToInnerTransition } from "./transitions/indexToInner.js";
import { IndexToMainTransition } from "./transitions/indexToMain.js";
import { InnerToMainTransition } from "./transitions/innerToMain.js";
import { InnerToIndexTransition } from "./transitions/innerToIndex.js";
import { MainToIndexTransition } from "./transitions/mainToIndex.js";
import { MainToInnerTransition } from "./transitions/mainToInner.js";
gsap.registerPlugin(SplitText);
// Carousel scroll tilt: the harder you scroll the "Selected" page, the more the
// planes rotate about their Y axis (perspective lean). Tilt is derived from the
// carousel's per-frame velocity (px), clamped, and eased toward so it springs
// back to flat when scrolling stops.
const TILT_RAD_PER_PX = 0.005;
const TILT_MAX_RAD = 0.05; // ~11 degrees
const TILT_LERP = 0.09;
// Inner-page scroll tilt: scrolling the detail stack tilts its planes about the
// X axis (a forward/back lean), driven by Lenis's per-frame velocity, clamped,
// and eased so it springs back to flat when scrolling stops.
const INNER_TILT_RAD_PER_PX = 0.003;
const INNER_TILT_MAX_RAD = 0.05; // ~7 degrees
const INNER_TILT_LERP = 0.09;
const TITLE_IN_DURATION = 0.7;
const TITLE_IN_STAGGER = 0.04;
const TITLE_OUT_DURATION = 0.45;
const TITLE_OUT_STAGGER = 0.025;
function animateTitleIn(sec) {
if (!sec) return null;
const h1 = sec.querySelector(".page-title");
if (!h1) return null;
const split = SplitText.create(h1, { type: "words", mask: "words" });
sec._titleSplit = split;
return gsap.from(split.words, {
yPercent: 102,
duration: TITLE_IN_DURATION,
stagger: TITLE_IN_STAGGER,
ease: "power3.out",
});
}
function animateTitleOut(sec) {
if (!sec) return null;
const split = sec._titleSplit;
if (!split) return null;
return gsap.to(split.words, {
yPercent: -102,
duration: TITLE_OUT_DURATION,
stagger: TITLE_OUT_STAGGER,
ease: "power3.out",
});
}
const FACT_IN_DURATION = 0.8;
const FACT_IN_STAGGER = 0.08;
const FACT_OUT_DURATION = 0.5;
const FACT_OUT_STAGGER = 0.05;
function animateFactIn(sec) {
if (!sec) return null;
const p = sec.querySelector(".inner-fact");
if (!p) return null;
const split = SplitText.create(p, { type: "lines", mask: "lines" });
sec._factSplit = split;
return gsap.from(split.lines, {
yPercent: 102,
duration: FACT_IN_DURATION,
stagger: FACT_IN_STAGGER,
ease: "power3.out",
delay: 0.1,
});
}
function animateFactOut(sec) {
if (!sec) return null;
const split = sec._factSplit;
if (!split) return null;
return gsap.to(split.lines, {
yPercent: -102,
duration: FACT_OUT_DURATION,
stagger: FACT_OUT_STAGGER,
ease: "power3.out",
});
}
const CAPTION_IN_DURATION = 0.7;
const CAPTION_IN_STAGGER = 0.06;
const CAPTION_OUT_DURATION = 0.45;
const CAPTION_OUT_STAGGER = 0.04;
function animateCaptionsIn(sec) {
if (!sec) return null;
const captions = sec.querySelectorAll(".slot-caption");
if (!captions.length) return null;
const allLines = [];
const splits = [];
for (const cap of captions) {
const split = SplitText.create(cap, { type: "lines", mask: "lines" });
splits.push(split);
allLines.push(...split.lines);
}
sec._captionSplits = splits;
return gsap.from(allLines, {
yPercent: 102,
duration: CAPTION_IN_DURATION,
stagger: CAPTION_IN_STAGGER,
ease: "power3.out",
delay: 0.15,
});
}
function animateCaptionsOut(sec) {
if (!sec) return null;
const splits = sec._captionSplits;
if (!splits || !splits.length) return null;
const allLines = [];
for (const split of splits) allLines.push(...split.lines);
return gsap.to(allLines, {
yPercent: -102,
duration: CAPTION_OUT_DURATION,
stagger: CAPTION_OUT_STAGGER,
ease: "power3.out",
});
}
// ---------------------------------------------------------------------------
// Intro: played once on the first page load (never on SPA transitions). The
// active planes fade up while the persistent chrome — the left nav and the
// footer links — rises in with the same masked split-text reveal the page
// title (top-right indicator) uses.
// ---------------------------------------------------------------------------
const INTRO_TEXT_DURATION = 0.7;
const INTRO_TEXT_STAGGER = 0.06;
const INTRO_NAV_DELAY = 0.1;
const INTRO_FOOTER_DELAY = 0.2;
const INTRO_PLANE_DURATION = 1.0;
const INTRO_PLANE_STAGGER = 0.08;
// Masked word reveal for a group of persistent chrome links (#nav / #footer),
// matching animateTitleIn. The split is reverted on completion so hover
// underlines and layout return to their original markup.
function animateChromeIn(selector, delay) {
const els = document.querySelectorAll(selector);
if (!els.length) return null;
const splits = [];
const words = [];
for (const el of els) {
const split = SplitText.create(el, { type: "words", mask: "words" });
splits.push(split);
words.push(...split.words);
}
// Links are hidden via CSS until now (avoids a flash while textures load);
// reveal them in the same tick the words are masked and offset below.
gsap.set(els, { opacity: 1 });
return gsap.from(words, {
yPercent: 102,
duration: INTRO_TEXT_DURATION,
stagger: INTRO_TEXT_STAGGER,
ease: "power3.out",
delay,
onComplete: () => splits.forEach((s) => s.revert()),
});
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
const ROUTES = {
"/": { page: "main", view: home, image: null },
"/index": { page: "index", view: indexPage, image: null },
"/1": { page: "inner", view: inner(0), image: 0 },
"/2": { page: "inner", view: inner(1), image: 1 },
"/3": { page: "inner", view: inner(2), image: 2 },
"/4": { page: "inner", view: inner(3), image: 3 },
"/5": { page: "inner", view: inner(4), image: 4 },
};
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
export class Controller {
constructor({ app, gpu, lenis }) {
this.app = app;
this.gpu = gpu;
this.lenis = lenis;
this.routes = ROUTES;
// Registry: from-page -> to-page -> Transition
this.transitions = {
"main->inner": new MainToInnerTransition(),
"inner->main": new InnerToMainTransition(),
"main->index": new MainToIndexTransition(),
"index->main": new IndexToMainTransition(),
"index->inner": new IndexToInnerTransition(),
"inner->index": new InnerToIndexTransition(),
};
this.current = null;
this.mutating = false;
this.carousel = null;
this.indexFloat = null;
this.onClick = this.onClick.bind(this);
this.onPopState = this.onPopState.bind(this);
}
async start() {
document.addEventListener("click", this.onClick);
window.addEventListener("popstate", this.onPopState);
this.gpu.onResizeLayout = () => this._reapplyLayout();
await this._renderInitial(window.location.pathname);
}
tick() {
if (this.carousel) {
this.carousel.tick();
this._applyCarouselTilt(this.carousel.velocity);
}
if (this.indexFloat) this.indexFloat.tick();
if (this.current?.page === "inner" && !this.mutating) {
this._applyInnerTilt(this.current.image, this.lenis.velocity ?? 0);
}
}
_applyCarouselTilt(velocity) {
let target = velocity * TILT_RAD_PER_PX;
if (target > TILT_MAX_RAD) target = TILT_MAX_RAD;
else if (target < -TILT_MAX_RAD) target = -TILT_MAX_RAD;
for (let i = 0; i < MAIN_COUNT; i++) {
const plane = this.gpu.planes[mainIdx(i)];
plane.tilt += (target - plane.tilt) * TILT_LERP;
}
}
_innerTiltPlanes(image) {
const planes = [this.gpu.planes[mainIdx(image)]];
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
planes.push(this.gpu.planes[satIdx(image, j)]);
}
return planes;
}
_applyInnerTilt(image, velocity) {
let target = velocity * INNER_TILT_RAD_PER_PX;
if (target > INNER_TILT_MAX_RAD) target = INNER_TILT_MAX_RAD;
else if (target < -INNER_TILT_MAX_RAD) target = -INNER_TILT_MAX_RAD;
for (const plane of this._innerTiltPlanes(image)) {
plane.tiltX += (target - plane.tiltX) * INNER_TILT_LERP;
}
}
_routeFor(path) {
return this.routes[path] ?? this.routes["/"];
}
async _renderInitial(path) {
const route = this._routeFor(path);
this.current = { path, ...route };
this.app.innerHTML = route.view();
this._snapLayout(this.current);
this._setActiveNav(this.current.page);
this._enterPage(this.current);
// Prime the intro: the page is built but everything stays hidden behind the
// preloader. _snapLayout lit the active planes, so remember which ones and
// drop them to zero; playIntro fades them back up once the preloader clears.
for (const p of this.gpu.planes) {
p.introVisible = p.opacity > 0.001;
p.opacity = 0;
}
}
// First-load intro, played once after the preloader fades away (never on SPA
// transitions). Reveals the page text, the left nav and footer links, and
// fades the active planes up from transparent.
playIntro() {
const sec = this.app.querySelector(`[data-page="${this.current.page}"]`);
animateTitleIn(sec);
animateFactIn(sec);
animateCaptionsIn(sec);
const planes = this.gpu.planes.filter((p) => p.introVisible);
if (planes.length) {
gsap.to(planes, {
opacity: 1,
duration: INTRO_PLANE_DURATION,
stagger: INTRO_PLANE_STAGGER,
ease: "power2.out",
});
}
animateChromeIn("#nav a", INTRO_NAV_DELAY);
animateChromeIn("#footer a", INTRO_FOOTER_DELAY);
}
_syncPlaneToEl(plane, el) {
plane.trackedEl = el;
const rect = el.getBoundingClientRect();
plane.bounds.x = rect.left;
plane.bounds.y = rect.top;
plane.bounds.w = rect.width;
plane.bounds.h = rect.height;
}
_enterPage(state) {
const sec = this.app.querySelector(`[data-page="${state.page}"]`);
if (!sec) return;
if (state.page === "main") {
document.body.style.height = "100vh";
this.lenis.stop();
this.lenis.scrollTo(0, { immediate: true, force: true });
if (!this.carousel) this.carousel = new Carousel(sec);
this.carousel.start();
const slots = sec.querySelectorAll(".slot");
for (let i = 0; i < slots.length && i < MAIN_COUNT; i++) {
this._syncPlaneToEl(this.gpu.planes[mainIdx(i)], slots[i]);
}
return;
}
if (state.page === "inner") {
const stack = sec.querySelector(".stack");
const slots = stack.querySelectorAll(".slot");
document.body.style.height = `${stack.offsetHeight}px`;
this.lenis.start();
this.lenis.resize();
this.lenis.scrollTo(0, { immediate: true, force: true });
this._syncPlaneToEl(this.gpu.planes[mainIdx(state.image)], slots[0]);
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const slot = slots[j + 1];
if (!slot) continue;
this._syncPlaneToEl(this.gpu.planes[satIdx(state.image, j)], slot);
}
return;
}
if (state.page === "index") {
document.body.style.height = "100vh";
this.lenis.stop();
this.lenis.scrollTo(0, { immediate: true, force: true });
if (!this.indexFloat) {
this.indexFloat = new IndexFloat(this.gpu);
this.indexFloat.prepare();
}
this.indexFloat.start();
return;
}
}
_leavePage(state) {
if (!state) return;
if (state.page === "main" && this.carousel) {
this.carousel.stop();
this.carousel = null;
// Clear any leftover scroll tilt so the main planes don't carry a lean
// into the inner/index pages, where they're reused as the hero image.
for (let i = 0; i < MAIN_COUNT; i++) this.gpu.planes[mainIdx(i)].tilt = 0;
}
if (state.page === "index" && this.indexFloat) {
this.indexFloat.stop();
this.indexFloat = null;
}
if (state.page === "inner") {
// Clear the scroll lean so the reused hero/satellite planes don't carry
// it onto the next page.
for (const plane of this._innerTiltPlanes(state.image)) plane.tiltX = 0;
}
for (const plane of this.gpu.planes) {
plane.trackedEl = null;
}
}
_setActiveNav(pageKey) {
// Only the main and index pages have a corresponding nav link; inner
// pages leave the nav with nothing active.
const activeKey =
pageKey === "main" ? "main" : pageKey === "index" ? "index" : null;
const links = document.querySelectorAll("#nav a[data-nav-key]");
for (const a of links) {
if (activeKey && a.getAttribute("data-nav-key") === activeKey) {
a.classList.add("is-active");
} else {
a.classList.remove("is-active");
}
}
}
_snapLayout(state) {
if (state.page === "main") this.gpu.applyMainLayout();
else if (state.page === "index") this.gpu.applyIndexLayout();
else if (state.page === "inner") this.gpu.applyInnerLayout(state.image);
}
_reapplyLayout() {
if (!this.current || this.mutating) return;
if (this.current.page === "main" && this.carousel) {
this.carousel.measure();
return;
}
if (this.current.page === "index" && this.indexFloat) {
this.indexFloat.measure();
return;
}
this._snapLayout(this.current);
}
_resolveTransition(from, to) {
return this.transitions[`${from}->${to}`];
}
async navigate(path, target = null) {
if (this.mutating) return;
if (path === this.current?.path) return;
const next = this.routes[path];
if (!next) return;
const fromState = this.current;
const toState = { path, ...next };
const transition = this._resolveTransition(fromState.page, next.page);
this.mutating = true;
if (target !== "back") history.pushState({ path }, "", path);
this._setActiveNav(next.page);
const fromElNow = this.app.children[0];
const titleOut = animateTitleOut(fromElNow);
const factOut = animateFactOut(fromElNow);
const captionsOut = animateCaptionsOut(fromElNow);
this._leavePage(fromState);
this.app.insertAdjacentHTML("beforeend", next.view());
const fromEl = this.app.children[0];
const toEl = this.app.lastElementChild;
if (fromEl) fromEl.style.pointerEvents = "none";
if (window.scrollY !== 0 || window.scrollX !== 0) {
this.lenis.scrollTo(0, { immediate: true, force: true });
window.scrollTo(0, 0);
}
if (next.page === "main") {
this.carousel = new Carousel(toEl);
this.carousel.prepare();
}
if (next.page === "index") {
this.indexFloat = new IndexFloat(this.gpu);
this.indexFloat.prepare();
}
const titleIn = animateTitleIn(toEl);
const factIn = animateFactIn(toEl);
const captionsIn = animateCaptionsIn(toEl);
const ctx = {
gpu: this.gpu,
fromImage: fromState.image,
toImage: next.image,
indexFloat: this.indexFloat,
};
const txOut = transition.out(fromEl, toEl, ctx);
const txIn = transition.in(fromEl, toEl, ctx);
await Promise.all([
titleOut,
titleIn,
factOut,
factIn,
captionsOut,
captionsIn,
txOut,
txIn,
]);
fromEl.remove();
this.current = toState;
this._snapLayout(toState);
this._enterPage(toState);
this.mutating = false;
}
onClick(e) {
const a = e.target.closest("a[data-link]");
if (!a) return;
e.preventDefault();
const href = a.getAttribute("href");
this.navigate(href);
}
onPopState() {
this.navigate(window.location.pathname, "back");
}
}
src/cursor.js
const LERP = 0.2;
const HOVER_SELECTOR = '.page-main .slot';
export class Cursor {
constructor() {
this.el = document.getElementById('cursor');
this.x = 0;
this.y = 0;
this.tx = 0;
this.ty = 0;
this.rafId = null;
this.hovering = false;
this.onMove = this.onMove.bind(this);
this.tick = this.tick.bind(this);
}
start() {
if (!this.el) return;
window.addEventListener('mousemove', this.onMove);
this.rafId = requestAnimationFrame(this.tick);
}
onMove(e) {
this.tx = e.clientX;
this.ty = e.clientY;
const el = document.elementFromPoint(e.clientX, e.clientY);
const isHot = !!el?.closest?.(HOVER_SELECTOR);
if (isHot !== this.hovering) {
this.hovering = isHot;
this.el.classList.toggle('is-hover', isHot);
}
}
tick() {
this.x += (this.tx - this.x) * LERP;
this.y += (this.ty - this.y) * LERP;
this.el.style.transform = `translate(${this.x}px, ${this.y}px) translate(-50%, -50%)`;
this.rafId = requestAnimationFrame(this.tick);
}
}
src/global.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: #ffffff;
width: 100%;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
font-family:
system-ui,
-apple-system,
sans-serif;
}
/* Intro overlay: covers the page on first load while the 0–100% counter runs,
then fades away (see Preloader) before the page intro plays. */
#preloader {
position: fixed;
inset: 0;
z-index: 2000;
background: #ffffff;
}
.preloader-count {
position: absolute;
top: 1.25rem;
left: 1.5rem;
font-size: 0.875rem;
letter-spacing: 0.02em;
color: #111;
font-variant-numeric: tabular-nums;
}
#cursor {
position: fixed;
top: 0;
left: 0;
width: 20px;
height: 20px;
background: transparent;
border: 1px solid #111;
border-radius: 0;
pointer-events: none;
z-index: 1000;
transition:
width 0.3s cubic-bezier(0.65, 0, 0.35, 1),
height 0.3s cubic-bezier(0.65, 0, 0.35, 1),
border-radius 0.3s cubic-bezier(0.65, 0, 0.35, 1);
will-change: transform;
}
#cursor::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
border: 1px solid #111;
border-radius: 0;
opacity: 0;
z-index: -1;
transform: translate(-50%, -50%) scale(1);
transform-origin: center center;
transition:
opacity 0.35s cubic-bezier(0.65, 0, 0.35, 1),
transform 0.35s cubic-bezier(0.65, 0, 0.35, 1),
border-radius 0.35s cubic-bezier(0.65, 0, 0.35, 1);
pointer-events: none;
}
#cursor.is-hover {
width: 60px;
height: 60px;
border-radius: 50%;
}
#cursor.is-hover::after {
border-radius: 50%;
opacity: 1;
transform: translate(-50%, -50%) scale(0.7);
}
@media (hover: none) {
#cursor {
display: none;
}
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
body {
color: #111;
}
/* Hidden until the first-load intro reveals them (see _playIntro). Prevents a
flash of resting nav/footer text while textures load before the intro runs. */
html.js #nav a,
html.js #footer a {
opacity: 0;
}
#nav {
position: fixed;
top: 1.25rem;
left: 1.5rem;
z-index: 200;
display: flex;
gap: 0.35rem;
font-size: 0.875rem;
letter-spacing: 0.02em;
pointer-events: none;
}
#nav a:not(:last-child)::after {
content: ",";
margin-left: 0.05rem;
}
.page-title {
position: fixed;
top: 1.25rem;
right: 1.5rem;
z-index: 10;
font-size: 0.875rem;
font-weight: 400;
line-height: 1;
letter-spacing: 0.02em;
color: #111;
opacity: 0.5;
pointer-events: none;
margin: 0;
}
#nav a {
position: relative;
color: #111;
text-decoration: none;
pointer-events: auto;
opacity: 0.5;
transition: opacity 0.15s ease;
}
#nav a::before {
content: "";
position: absolute;
left: 0;
bottom: -3px;
width: 100%;
height: 1px;
background: currentColor;
transform: scaleX(0);
transform-origin: right center;
transition: transform 0.45s cubic-bezier(0.77, 0, 0.18, 1);
pointer-events: none;
}
#nav a:hover::before,
#nav a.is-active::before {
transform: scaleX(1);
transform-origin: left center;
}
.inner-fact {
position: fixed;
top: 10rem;
left: 1.5rem;
z-index: 10;
max-width: 16rem;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.35;
letter-spacing: 0.02em;
color: #111;
pointer-events: none;
margin: 0;
}
#nav a:hover,
#nav a.is-active {
opacity: 1;
}
#footer {
position: fixed;
bottom: 1.25rem;
left: 1.5rem;
right: 1.5rem;
z-index: 200;
display: flex;
justify-content: space-between;
font-size: 0.875rem;
letter-spacing: 0.02em;
pointer-events: none;
}
#footer a {
position: relative;
color: #111;
text-decoration: none;
pointer-events: auto;
opacity: 0.5;
transition: opacity 0.15s ease;
}
#footer a::before {
content: "";
position: absolute;
left: 0;
bottom: -3px;
width: 100%;
height: 1px;
background: currentColor;
transform: scaleX(0);
transform-origin: right center;
transition: transform 0.45s cubic-bezier(0.77, 0, 0.18, 1);
pointer-events: none;
}
#footer a:hover::before {
transform: scaleX(1);
transform-origin: left center;
}
#footer a:hover {
opacity: 1;
}
#footer div a:not(:last-child)::after {
content: ",";
margin-left: 0.05rem;
}
#gpu-canvas {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
z-index: 100;
display: block;
pointer-events: none;
}
#app {
position: relative;
width: 100%;
min-height: 100vh;
}
.page {
position: absolute;
top: 0;
left: 0;
width: 100%;
min-height: 100vh;
pointer-events: none;
}
.page-main .slot {
position: fixed;
top: 0;
left: 0;
width: 23vw;
display: block;
pointer-events: auto;
will-change: transform;
}
.page-main .slot figure {
width: 100%;
height: 100%;
display: block;
}
.page-main .slot-caption {
position: absolute;
top: 100%;
left: 0;
right: 0;
padding-top: 0.5rem;
font-size: 0.875rem;
letter-spacing: 0.02em;
color: #111;
opacity: 0.5;
pointer-events: none;
}
.page-main .slot-0 {
aspect-ratio: 4907 / 7360;
}
.page-main .slot-1 {
aspect-ratio: 6000 / 4000;
}
.page-main .slot-2 {
aspect-ratio: 3634 / 5998;
}
.page-main .slot-3 {
aspect-ratio: 3769 / 5025;
}
.page-main .slot-4 {
aspect-ratio: 6804 / 4104;
}
.page-inner .stack {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 48px;
width: 100%;
padding-top: calc((100vh - var(--cell-h)) / 2);
padding-bottom: calc((100vh - var(--cell-h)) / 2);
}
.page-inner .slot {
width: 23vw;
display: block;
flex-shrink: 0;
}
.page-inner .slot figure {
width: 100%;
height: 100%;
display: block;
}
.page-inner[data-image="0"] .slot {
aspect-ratio: 4907 / 7360;
}
.page-inner[data-image="1"] .slot {
aspect-ratio: 6000 / 4000;
}
.page-inner[data-image="2"] .slot {
aspect-ratio: 3634 / 5998;
}
.page-inner[data-image="3"] .slot {
aspect-ratio: 3769 / 5025;
}
.page-inner[data-image="4"] .slot {
aspect-ratio: 6804 / 4104;
}
.page-inner[data-image="0"] {
--cell-h: calc(23vw * 7360 / 4907);
}
.page-inner[data-image="1"] {
--cell-h: calc(23vw * 4000 / 6000);
}
.page-inner[data-image="2"] {
--cell-h: calc(23vw * 5998 / 3634);
}
.page-inner[data-image="3"] {
--cell-h: calc(23vw * 5025 / 3769);
}
.page-inner[data-image="4"] {
--cell-h: calc(23vw * 4104 / 6804);
}
src/gpu.js
import * as THREE from "three/webgpu";
import { Fn, uv, uniform, vec2 } from "three/tsl";
// Corner radius in CSS pixels, kept subtle.
const CORNER_RADIUS = 8;
// Camera distance from the z=0 plane. The perspective FOV is matched to this so
// the z=0 plane maps 1:1 to CSS pixels (identical framing to an ortho camera);
// smaller values = stronger perspective for out-of-plane tilts.
const CAMERA_DISTANCE = 1000;
// Builds an opacity node that masks the quad into a rounded rectangle using a
// rounded-box SDF. `sizeUniform` is the plane's pixel size (so the radius is a
// constant pixel value regardless of the plane's dimensions) and the result is
// multiplied by `opacityUniform`, the plane's fade value.
function roundedRectOpacityNode(sizeUniform, radiusUniform, opacityUniform) {
const mask = Fn(() => {
const half = sizeUniform.mul(0.5);
const r = radiusUniform.min(half.x).min(half.y);
const p = uv().sub(0.5).mul(sizeUniform);
const q = p.abs().sub(half).add(r);
const dist = q.max(vec2(0.0)).length().add(q.x.max(q.y).min(0.0)).sub(r);
// ~2px feather for antialiased edges; 1 inside, 0 outside.
return dist.smoothstep(-1.0, 1.0).oneMinus();
})();
return mask.mul(opacityUniform);
}
const IMAGES = [
"/images/christian-regg-FNaFLvbLFuk-unsplash.webp",
"/images/fabrizio-conti-rMWmDMeaoBk-unsplash.webp",
"/images/johannes-andersson-UCd78vfC8vU-unsplash.webp",
"/images/mads-schmidt-rasmussen-xfngap_DToE-unsplash.webp",
"/images/weichao-deng-eyn0LjpNWV4-unsplash.webp",
];
export const MAIN_COUNT = IMAGES.length; // 5
export const SATELLITES_PER_IMAGE = 4; // 4 satellites per image
export const SATELLITE_COUNT = MAIN_COUNT * SATELLITES_PER_IMAGE; // 20
export const TOTAL_PLANES = MAIN_COUNT + SATELLITE_COUNT; // 25
export function mainIdx(image) {
return image;
}
export function satIdx(image, j) {
return MAIN_COUNT + image * SATELLITES_PER_IMAGE + j;
}
export class GPU {
constructor() {
this.canvas = null;
this.renderer = null;
this.scene = null;
this.camera = null;
this.geometry = null;
this.textures = [];
this.aspects = [];
this.planes = [];
this.radiusUniform = uniform(CORNER_RADIUS);
this.onResize = this.onResize.bind(this);
this.onResizeLayout = null;
}
_createPlaneMaterial(texture) {
// NodeMaterial (not the classic MeshBasicMaterial) is required for
// `opacityNode`; the classic material silently ignores node properties.
const material = new THREE.MeshBasicNodeMaterial({
map: texture,
transparent: true,
});
const sizeUniform = uniform(new THREE.Vector2(1, 1));
const opacityUniform = uniform(0);
material.opacityNode = roundedRectOpacityNode(sizeUniform, this.radiusUniform, opacityUniform);
return { material, sizeUniform, opacityUniform };
}
async init() {
this.canvas = document.createElement("canvas");
this.canvas.id = "gpu-canvas";
document.body.append(this.canvas);
this.renderer = new THREE.WebGPURenderer({
canvas: this.canvas,
antialias: true,
alpha: true,
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(window.innerWidth, window.innerHeight);
await this.renderer.init();
this.scene = new THREE.Scene();
this.geometry = new THREE.PlaneGeometry(1, 1);
this.setupCamera();
await this.loadTextures();
this.createPlanes();
await this.warmup();
window.addEventListener("resize", this.onResize);
}
// Compile every plane's pipeline and upload its texture to the GPU up front,
// while the preloader is still on screen. Without this, the work happens on
// the first frame the planes turn visible — exactly when the intro plays —
// and the animation hitches. The planes start at opacity 0, so the warm
// render draws nothing (and sits behind the preloader regardless).
async warmup() {
await this.renderer.compileAsync(this.scene, this.camera);
this.renderer.render(this.scene, this.camera);
}
setupCamera() {
const w = window.innerWidth;
const h = window.innerHeight;
const fov = 2 * Math.atan(h / 2 / CAMERA_DISTANCE) * (180 / Math.PI);
this.camera = new THREE.PerspectiveCamera(fov, w / h, 0.1, CAMERA_DISTANCE * 2);
this.camera.position.z = CAMERA_DISTANCE;
}
async loadTextures() {
const loader = new THREE.TextureLoader();
await Promise.all(
IMAGES.map(
(src, i) =>
new Promise((resolve, reject) =>
loader.load(
src,
(tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
this.textures[i] = tex;
this.aspects[i] = tex.image.naturalWidth / tex.image.naturalHeight;
resolve();
},
undefined,
reject,
),
),
),
);
}
createPlanes() {
for (let i = 0; i < MAIN_COUNT; i++) {
const { material, sizeUniform, opacityUniform } = this._createPlaneMaterial(this.textures[i]);
const mesh = new THREE.Mesh(this.geometry, material);
this.scene.add(mesh);
this.planes.push({
mesh,
material,
sizeUniform,
opacityUniform,
bounds: { x: 0, y: 0, w: 0, h: 0, z: 0 },
opacity: 0,
tilt: 0,
tiltX: 0,
trackedEl: null,
kind: "main",
image: i,
});
}
// 20 satellite planes (4 per image)
for (let i = 0; i < MAIN_COUNT; i++) {
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const { material, sizeUniform, opacityUniform } = this._createPlaneMaterial(
this.textures[i],
);
const mesh = new THREE.Mesh(this.geometry, material);
this.scene.add(mesh);
this.planes.push({
mesh,
material,
sizeUniform,
opacityUniform,
bounds: { x: 0, y: 0, w: 0, h: 0, z: 0 },
opacity: 0,
tilt: 0,
tiltX: 0,
trackedEl: null,
kind: "satellite",
image: i,
j,
});
}
}
}
syncMesh(plane) {
const { mesh, bounds, opacity } = plane;
const w = window.innerWidth;
const h = window.innerHeight;
const pw = Math.max(bounds.w, 0.001);
const ph = Math.max(bounds.h, 0.001);
mesh.scale.set(pw, ph, 1);
mesh.position.x = bounds.x + bounds.w / 2 - w / 2;
mesh.position.y = -(bounds.y + bounds.h / 2 - h / 2);
mesh.rotation.x = plane.tiltX ?? 0;
mesh.rotation.y = plane.tilt ?? 0;
mesh.renderOrder = -(bounds.z ?? 0);
plane.sizeUniform.value.set(pw, ph);
plane.opacityUniform.value = opacity;
mesh.visible = opacity > 0.001 && bounds.w > 0;
}
applyMainLayout() {
for (let i = 0; i < MAIN_COUNT; i++) {
this.planes[mainIdx(i)].opacity = 1;
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
this.planes[satIdx(i, j)].opacity = 0;
}
}
}
applyInnerLayout(image) {
for (let i = 0; i < MAIN_COUNT; i++) {
this.planes[mainIdx(i)].opacity = i === image ? 1 : 0;
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
this.planes[satIdx(i, j)].opacity = i === image ? 1 : 0;
}
}
}
applyIndexLayout() {
for (let i = 0; i < MAIN_COUNT; i++) {
this.planes[mainIdx(i)].opacity = 1;
// Satellites are not part of the float constellation, hidden on /index.
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
this.planes[satIdx(i, j)].opacity = 0;
}
}
}
onResize() {
const w = window.innerWidth;
const h = window.innerHeight;
this.camera.fov = 2 * Math.atan(h / 2 / CAMERA_DISTANCE) * (180 / Math.PI);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
if (this.onResizeLayout) this.onResizeLayout();
}
update() {
for (const p of this.planes) {
if (p.trackedEl) {
const rect = p.trackedEl.getBoundingClientRect();
p.bounds.x = rect.left;
p.bounds.y = rect.top;
p.bounds.w = rect.width;
p.bounds.h = rect.height;
}
this.syncMesh(p);
}
this.renderer.render(this.scene, this.camera);
}
destroy() {
window.removeEventListener("resize", this.onResize);
this.renderer?.dispose();
this.canvas?.remove();
}
}
src/index.js
import Lenis from "lenis";
import { GPU } from "./gpu.js";
import { Controller } from "./controller.js";
import { Cursor } from "./cursor.js";
import { Preloader } from "./preloader.js";
async function start() {
const lenis = new Lenis({
smoothWheel: true,
syncTouch: true,
lerp: 0.09,
});
// Count up while textures load and the page is built behind the overlay.
const preloader = new Preloader();
const counting = preloader.count();
const gpu = new GPU();
await gpu.init();
const controller = new Controller({
app: document.getElementById("app"),
gpu,
lenis,
});
await controller.start();
function raf(time) {
lenis.raf(time);
controller.tick();
gpu.update();
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
// Hold until the counter reaches 100%, then lift the overlay and, in the same
// tick, play the page intro so its hidden start states are applied before the
// overlay clears (no flash of resting content).
await counting;
controller.playIntro();
preloader.reveal();
const cursor = new Cursor();
cursor.start();
}
start();
src/pages/home.js
const CAPTIONS = [
"Seealpsee",
"K2",
"The North Face",
"Mount Cook",
"Mount Everest",
];
export function home() {
const slots = [0, 1, 2, 3, 4]
.map(
(i) =>
`<a href="/${i + 1}" data-link class="slot slot-${i}"><figure></figure><div class="slot-caption">${CAPTIONS[i]}</div></a>`,
)
.join("");
return `
<section data-page="main" class="page page-main">
<h1 class="page-title">Selected</h1>
<div class="carousel">${slots}</div>
</section>
`;
}
export function getMainTargets(rootEl) {
const slots = rootEl.querySelectorAll(".slot");
const rects = [];
for (let i = 0; i < slots.length; i++) {
const r = slots[i].getBoundingClientRect();
rects.push({ x: r.left, y: r.top, w: r.width, h: r.height });
}
return rects;
}
src/pages/index-page.js
const BASE_HEIGHT_VH = 0.22;
const VIEWPORT_PADDING = 0.3;
const FOCAL_LENGTH = 600;
const Z_SPREAD = 350;
const NEAR_CLIP = 50;
const DRAG_SENSITIVITY = 0.003;
const MOMENTUM_DECAY = 0.95;
const MOMENTUM_MIN = 0.00005;
function seededRandom(seed) {
let t = (seed + 0x6d2b79f5) | 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function index() {
return `
<section data-page="index" class="page page-index">
<h1 class="page-title">Index</h1>
</section>
`;
}
export class IndexFloat {
constructor(gpu) {
this.gpu = gpu;
this.worldPositions = [];
this.baseSizes = [];
this.targets = []; // rects in plane-index order, captured by prepare()
this.rotX = 0;
this.rotY = 0;
this.velX = 0;
this.velY = 0;
this.isDragging = false;
this.lastX = 0;
this.lastY = 0;
this._running = false;
this._onDown = this._onDown.bind(this);
this._onMove = this._onMove.bind(this);
this._onUp = this._onUp.bind(this);
}
prepare() {
this.computeLayout();
this.targets = this._project();
}
computeLayout() {
const vw = window.innerWidth;
const vh = window.innerHeight;
this.worldPositions = [];
this.baseSizes = [];
for (let i = 0; i < this.gpu.planes.length; i++) {
const plane = this.gpu.planes[i];
if (plane.kind !== 'main') continue;
const aspect = this.gpu.aspects[plane.image] ?? 4 / 5;
const zNorm = seededRandom(i * 31 + 7);
const zWorld = (zNorm - 0.5) * 2 * Z_SPREAD;
const baseH = vh * BASE_HEIGHT_VH;
const baseW = baseH * aspect;
const xRange = vw * (1 - 2 * VIEWPORT_PADDING);
const yRange = vh * (1 - 2 * VIEWPORT_PADDING);
const xWorld = -xRange / 2 + seededRandom(i * 17 + 3) * xRange;
const yWorld = -yRange / 2 + seededRandom(i * 23 + 11) * yRange;
this.worldPositions[i] = { x: xWorld, y: yWorld, z: zWorld };
this.baseSizes[i] = { w: baseW, h: baseH };
}
}
_project() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const cosX = Math.cos(this.rotX);
const sinX = Math.sin(this.rotX);
const cosY = Math.cos(this.rotY);
const sinY = Math.sin(this.rotY);
const rects = [];
for (let i = 0; i < this.gpu.planes.length; i++) {
const pos = this.worldPositions[i];
const size = this.baseSizes[i];
if (!pos || !size) continue;
const rx = pos.x * cosY + pos.z * sinY;
let ry = pos.y;
let rz = -pos.x * sinY + pos.z * cosY;
const ry2 = ry * cosX - rz * sinX;
const rz2 = ry * sinX + rz * cosX;
ry = ry2;
rz = rz2;
const depth = FOCAL_LENGTH + rz;
if (depth < NEAR_CLIP) {
rects[i] = { x: -9999, y: -9999, w: 0, h: 0, z: rz };
continue;
}
const f = FOCAL_LENGTH / depth;
const w = size.w * f;
const h = size.h * f;
const cx = rx * f + vw / 2;
const cy = ry * f + vh / 2;
rects[i] = { x: cx - w / 2, y: cy - h / 2, w, h, z: rz };
}
return rects;
}
applyProjection() {
const rects = this._project();
for (let i = 0; i < this.gpu.planes.length; i++) {
const r = rects[i];
if (!r) continue;
const plane = this.gpu.planes[i];
plane.bounds.x = r.x;
plane.bounds.y = r.y;
plane.bounds.w = r.w;
plane.bounds.h = r.h;
plane.bounds.z = r.z;
}
}
start() {
this._running = true;
this.applyProjection();
window.addEventListener('pointerdown', this._onDown);
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
}
stop() {
this._running = false;
window.removeEventListener('pointerdown', this._onDown);
window.removeEventListener('pointermove', this._onMove);
window.removeEventListener('pointerup', this._onUp);
this.isDragging = false;
this.velX = 0;
this.velY = 0;
}
measure() {
this.rotX = 0;
this.rotY = 0;
this.velX = 0;
this.velY = 0;
this.computeLayout();
this.targets = this._project();
this.applyProjection();
}
getTargets() {
return this.targets;
}
tick() {
if (!this._running) return;
if (this.isDragging) return;
if (
Math.abs(this.velX) < MOMENTUM_MIN &&
Math.abs(this.velY) < MOMENTUM_MIN
) {
this.velX = 0;
this.velY = 0;
return;
}
this.rotX += this.velX;
this.rotY += this.velY;
this.velX *= MOMENTUM_DECAY;
this.velY *= MOMENTUM_DECAY;
this.applyProjection();
}
_onDown(e) {
if (e.target?.closest?.('a')) return;
this.isDragging = true;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.velX = 0;
this.velY = 0;
}
_onMove(e) {
if (!this.isDragging) return;
const dx = e.clientX - this.lastX;
const dy = e.clientY - this.lastY;
this.lastX = e.clientX;
this.lastY = e.clientY;
this.rotY += dx * DRAG_SENSITIVITY;
this.rotX += dy * DRAG_SENSITIVITY;
this.velY = dx * DRAG_SENSITIVITY;
this.velX = dy * DRAG_SENSITIVITY;
this.applyProjection();
}
_onUp() {
this.isDragging = false;
}
}
src/pages/inner.js
export const INNER_X_OFFSETS_VW = [0, -14, 10, -6, 16];
// Keyed by image index, aligned to the home-page captions:
// 0 Seealpsee · 1 K2 · 2 The North Face · 3 Mount Cook · 4 Mount Everest
const FACTS = [
"Seealpsee lies at eleven hundred meters in the Appenzell Alps, a sliver of meltwater cupped beneath the limestone walls of the Säntis. For much of the day the cliffs keep it in shadow, and its surface settles into a still, glassy green.",
"K2 rises to 8,611 meters on the Pakistan-China border, the second-highest point on Earth and by far the more dangerous to climb. It went unclimbed in winter until 2021, decades after every other eight-thousander had already fallen.",
"A mountain's north face turns away from the sun, so its ice never fully lets go. The Eiger's is eighteen hundred meters of it, a wall so deadly that climbers renamed the Nordwand the Mordwand, the murder wall.",
"Aoraki, or Mount Cook, is New Zealand's highest peak at 3,724 meters, though a 1991 rockfall sheared roughly ten meters off its summit overnight. Edmund Hillary trained on its slopes before he ever set eyes on Everest.",
"Mount Everest grows roughly four millimeters taller each year as the Indian plate keeps pushing into Asia. Its summit was once seabed, and fossilized marine creatures are still found near the top.",
];
export function inner(image) {
return function innerView() {
const slots = [0, 1, 2, 3, 4]
.map(
(i) =>
`<div class="slot" style="transform: translateX(${INNER_X_OFFSETS_VW[i] ?? 0}vw);"><figure></figure></div>`,
)
.join('');
return `
<section data-page="inner" data-image="${image}" class="page page-inner">
<h1 class="page-title">Inner</h1>
<p class="inner-fact">${FACTS[image]}</p>
<div class="stack">${slots}</div>
</section>
`;
};
}
export function getInnerTargets(rootEl) {
const slots = rootEl.querySelectorAll('.stack .slot');
const rects = [];
for (let i = 0; i < slots.length; i++) {
const r = slots[i].getBoundingClientRect();
rects.push({ x: r.left, y: r.top, w: r.width, h: r.height });
}
return rects;
}
src/preloader.js
import gsap from "gsap";
// Full-screen intro overlay with a 0–100% counter in the top-left corner. It
// counts up (run in parallel with texture loading) and then fades away, after
// which the caller plays the page intro. Driven from #preloader in index.html
// so it paints on the very first frame, before this module even runs.
const COUNT_DURATION = 1.6;
const FADE_DURATION = 0.6;
export class Preloader {
constructor() {
this.el = document.getElementById("preloader");
this.countEl = this.el?.querySelector(".preloader-count") ?? null;
}
// Count from 0 to 100. Resolves once the counter has reached 100%.
async count() {
if (!this.countEl) return;
const counter = { value: 0 };
await gsap.to(counter, {
value: 100,
duration: COUNT_DURATION,
ease: "power1.inOut",
onUpdate: () => {
this.countEl.textContent = `${Math.round(counter.value)}%`;
},
});
}
// Fade the overlay out and remove it from the stacking/interaction flow.
async reveal() {
if (!this.el) return;
await gsap.to(this.el, {
autoAlpha: 0,
duration: FADE_DURATION,
ease: "power2.inOut",
});
this.el.style.display = "none";
}
}
src/transitions/constants.js
// ---------------------------------------------------------------------------
// Tween constants
// ---------------------------------------------------------------------------
import gsap from 'gsap'
export const DUR_MORPH = 0.9;
export const DUR_FADE = 0.5;
export const EASE_MORPH = 'power3.inOut';
export const EASE_FADE = 'power2.out';
export const EASE_FADE_OUT = 'power2.in';
export function tweenBounds(plane, target, opts = {}) {
return gsap.to(plane.bounds, {
x: target.x,
y: target.y,
w: target.w,
h: target.h,
z: target.z ?? 0,
duration: opts.duration ?? DUR_MORPH,
ease: opts.ease ?? EASE_MORPH,
delay: opts.delay ?? 0,
});
}
export function tweenOpacity(plane, to, opts = {}) {
return gsap.to(plane, {
opacity: to,
duration: opts.duration ?? DUR_FADE,
ease: opts.ease ?? EASE_FADE,
delay: opts.delay ?? 0,
});
}
src/transitions/indexToInner.js
import {
MAIN_COUNT,
SATELLITES_PER_IMAGE,
mainIdx,
satIdx,
} from '../gpu.js';
import { getInnerTargets } from '../pages/inner.js';
import {
tweenBounds,
tweenOpacity,
DUR_FADE,
EASE_FADE_OUT,
} from './constants.js';
export class IndexToInnerTransition {
async out(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl);
const tweens = [];
tweens.push(tweenBounds(gpu.planes[mainIdx(toImage)], innerRects[0]));
for (let i = 0; i < MAIN_COUNT; i++) {
if (i === toImage) continue;
tweens.push(
tweenOpacity(gpu.planes[mainIdx(i)], 0, {
duration: DUR_FADE * 0.7,
ease: EASE_FADE_OUT,
}),
);
}
await Promise.all(tweens);
}
// 4 satellites for the target image stamp at slots 1..4 and fade in.
async in(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl);
const fades = [];
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const sat = gpu.planes[satIdx(toImage, j)];
sat.bounds = { ...innerRects[j + 1] };
sat.opacity = 0;
fades.push(
tweenOpacity(sat, 1, {
delay: 0.25 + j * 0.08,
}),
);
}
await Promise.all(fades);
}
}
src/transitions/indexToMain.js
import { MAIN_COUNT, mainIdx } from '../gpu.js';
import { getMainTargets } from '../pages/home.js';
import { tweenBounds } from './constants.js';
export class IndexToMainTransition {
async out(_from, toEl, ctx) {
const { gpu } = ctx;
const mainRects = getMainTargets(toEl);
const tweens = [];
for (let i = 0; i < MAIN_COUNT; i++) {
tweens.push(tweenBounds(gpu.planes[mainIdx(i)], mainRects[i]));
}
await Promise.all(tweens);
}
async in(_from, _to, _ctx) {
// No fade-in needed; the 5 mains morph in via out().
}
}
src/transitions/innerToIndex.js
import {
MAIN_COUNT,
SATELLITES_PER_IMAGE,
mainIdx,
satIdx,
} from '../gpu.js';
import {
tweenBounds,
tweenOpacity,
DUR_FADE,
EASE_FADE_OUT,
} from './constants.js';
export class InnerToIndexTransition {
async out(_from, _to, ctx) {
const { gpu, fromImage, indexFloat } = ctx;
const targets = indexFloat.getTargets();
const tweens = [];
tweens.push(
tweenBounds(gpu.planes[mainIdx(fromImage)], targets[mainIdx(fromImage)]),
);
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const sat = gpu.planes[satIdx(fromImage, j)];
tweens.push(
tweenOpacity(sat, 0, {
duration: DUR_FADE * 0.7,
ease: EASE_FADE_OUT,
delay: (SATELLITES_PER_IMAGE - 1 - j) * 0.05,
}),
);
}
await Promise.all(tweens);
}
// Stamp the other 4 mains at their float positions and fade them in.
async in(_from, _to, ctx) {
const { gpu, fromImage, indexFloat } = ctx;
const targets = indexFloat.getTargets();
const fades = [];
for (let i = 0; i < MAIN_COUNT; i++) {
if (i === fromImage) continue;
const main = gpu.planes[mainIdx(i)];
main.bounds = { ...targets[mainIdx(i)] };
main.opacity = 0;
fades.push(tweenOpacity(main, 1, { delay: 0.2 + i * 0.04 }));
}
await Promise.all(fades);
}
}
src/transitions/innerToMain.js
import {
MAIN_COUNT,
SATELLITES_PER_IMAGE,
mainIdx,
satIdx,
} from '../gpu.js';
import { getMainTargets } from '../pages/home.js';
import {
tweenBounds,
tweenOpacity,
DUR_FADE,
EASE_FADE_OUT,
} from './constants.js';
export class InnerToMainTransition {
async out(_from, toEl, ctx) {
const { gpu, fromImage } = ctx;
const mainRects = getMainTargets(toEl);
const target = mainRects[fromImage];
const tweens = [];
tweens.push(tweenBounds(gpu.planes[mainIdx(fromImage)], target));
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const sat = gpu.planes[satIdx(fromImage, j)];
const reversedDelay = (SATELLITES_PER_IMAGE - 1 - j) * 0.05;
tweens.push(
tweenOpacity(sat, 0, {
duration: DUR_FADE * 0.7,
ease: EASE_FADE_OUT,
delay: reversedDelay,
}),
);
}
await Promise.all(tweens);
}
// The other 4 main planes fade in at their horizontal carousel slots.
async in(_from, toEl, ctx) {
const { gpu, fromImage } = ctx;
const mainRects = getMainTargets(toEl);
const fades = [];
for (let i = 0; i < MAIN_COUNT; i++) {
if (i === fromImage) continue;
const main = gpu.planes[mainIdx(i)];
main.bounds = { ...mainRects[i] };
main.opacity = 0;
fades.push(tweenOpacity(main, 1, { delay: 0.25 }));
}
await Promise.all(fades);
}
}
src/transitions/mainToIndex.js
import { MAIN_COUNT, mainIdx } from '../gpu.js';
import { tweenBounds } from './constants.js';
export class MainToIndexTransition {
async out(_from, _to, ctx) {
const { gpu, indexFloat } = ctx;
const targets = indexFloat.getTargets();
const tweens = [];
for (let i = 0; i < MAIN_COUNT; i++) {
tweens.push(tweenBounds(gpu.planes[mainIdx(i)], targets[mainIdx(i)]));
}
await Promise.all(tweens);
}
async in(_from, _to, _ctx) {
// Mains morph in via out(); sats are hidden on /index.
}
}
src/transitions/mainToInner.js
import {
MAIN_COUNT,
SATELLITES_PER_IMAGE,
mainIdx,
satIdx,
} from '../gpu.js';
import { getInnerTargets } from '../pages/inner.js';
import { tweenBounds, tweenOpacity } from './constants.js';
export class MainToInnerTransition {
async out(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl);
const target = innerRects[0];
const tweens = [];
for (let i = 0; i < MAIN_COUNT; i++) {
const plane = gpu.planes[mainIdx(i)];
if (i === toImage) {
tweens.push(tweenBounds(plane, target));
continue;
}
tweens.push(tweenOpacity(plane, 0));
}
await Promise.all(tweens);
}
async in(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl);
const fades = [];
for (let j = 0; j < SATELLITES_PER_IMAGE; j++) {
const sat = gpu.planes[satIdx(toImage, j)];
sat.bounds = { ...innerRects[j + 1] };
sat.opacity = 0;
fades.push(
tweenOpacity(sat, 1, {
delay: 0.25 + j * 0.08,
}),
);
}
await Promise.all(fades);
}
}
vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
},
});
Media credits and license evidence실행 안내·자료
README.md
## Credits
Images from Unsplash:
- [Mads Schmidt Rasmussen](https://unsplash.com/photos/ice-capped-mountain-at-daytime-xfngap_DToE)
- [Christian Regg](https://unsplash.com/photos/house-on-near-body-of-water-and-mountain-FNaFLvbLFuk)
- [Johannes Andersson](https://unsplash.com/photos/two-brown-deer-beside-trees-and-mountain-UCd78vfC8vU)
- [Weichao Deng](https://unsplash.com/photos/a-snow-covered-mountain-with-clouds-in-the-sky-eyn0LjpNWV4)
- [Fabrizio Conti](https://unsplash.com/photos/landscape-photography-of-white-and-black-mountain-rMWmDMeaoBk)
## License
[MIT](LICENSE)
LICENSE실행 안내·자료
MIT License
Copyright (c) 2009 - 2025 [Codrops](https://codrops.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.
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
lenis@1.3.26 — LICENSE
The MIT License
Copyright (c) 2024 darkroom.engineering
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.
three@0.184.0 — LICENSE
The MIT License
Copyright © 2010-2026 three.js authors
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
이 장면을 만드는 원리
오래 사는 GPU 장면과 페이지 교체의 완료 조건
반복 입력에서 현재 의도와 전환의 종료·취소 책임을 명시합니다.
- 이 예제에서는
- navigate는 이전 페이지 입력을 막고 새 DOM을 추가한 뒤 텍스트와 입출장 전환들을 Promise.all로 기다립니다. 완료 후 이전 DOM을 제거하며 mutating 동안 다른 탐색을 거절합니다.
코드와 함께 확인하기
코드에서 찾기
navigatecontroller.js히스토리 갱신·DOM 교체·전환 대기·최종 상태 확정을 순서대로 수행합니다.
직접 해보기
전환 도중 뒤로 가기를 누르고 전환 하나가 실패하는 경우를 만듭니다.
살펴볼 변화URL과 현재 페이지가 갈라지거나 mutating 잠금이 남지 않는지 확인해야 합니다. 완료 경로만으로 실패 복구가 증명되지는 않습니다.
