Panel Wipe Page Transition
A three-page demo where every navigation runs one timeline: a surface panel and a lime panel wipe up from the bottom (0.8s power4.inOut, stagger 0.08), the page swaps beneath, then they exit upward as the new content rises in.
- Page Transitions
- gsap
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1611 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Panel Wipe Page Transition
**What it does (visual):** A small demo "app" with a sticky top bar (brand on the left, a fake URL `ferrule.studio/home` in the centre, Home / Work / Studio links on the right with a 1px `#d4ff3a` underline on the active one) and three full-height pages: *Home* (big serif headline "Rooms that breathe." beside a large warm glowing orb), *Work* (a headline over three tall gradient tiles) and *Studio* (right-aligned headline and address over a wide teal-to-lime gradient band). Clicking any link runs the transition: a dark surface-coloured sheet rises from the bottom of the viewport and covers it in 0.8s with the `power4.inOut` curve (accelerating then decelerating hard); an accent sheet (`#d4ff3a`) follows 0.08s behind it, so for a beat the accent is a thick stripe racing up under the dark one. The instant the screen is fully covered, the old page is replaced by the new one underneath. Then the sheets continue upward and out through the top — accent first, dark 0.08s later — over another 0.8s, and as they lift, the new page's eyebrow, headline, paragraph and artwork rise 40px into place and fade in over 1s, staggered 60ms apart. The URL label and the active link update during the covered moment. The whole gesture takes ~1.9s. On first load the demo plays one Home → Work transition by itself 1.2s after the fonts are ready, so the choreography is visible without clicking.
**Tech stack:**
- GSAP 3.13+ core only (timeline, stagger, callbacks). No plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Works standalone (hash navigation) or hooked into Astro view transitions, Next.js App Router or Barba — see below.
**HTML structure:**
```html
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work"> <!-- headline + <ul class="pw__tiles"> of 3 .pw__rise.pw__tile --> </section>
<section class="pw__page pw__page--studio" data-page="studio"> <!-- right-aligned copy + .pw__art--band --> </section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
```
Everything that should rise in on a new page carries the class `pw__rise`.
**CSS requirements:**
- `.pw`: `min-height: 100vh; display: flex; flex-direction: column;`
- `.pw__nav`: `position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: 3rem; border-bottom: 1px solid var(--line, rgba(255,255,255,.08)); background: var(--bg);`
- `.pw__link::after`: 1px `var(--accent, #d4ff3a)` underline, `transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1);` → `scaleX(1)` on `.is-active`.
- `.pw__stage`: `position: relative; flex: 1; display: grid;` — `.pw__page`: `grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: 4rem 3rem;` and `.pw__page.is-active { display: grid; }` (pages stack in the same cell; only one is displayed).
- `.pw__h`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em;`. `.pw__eyebrow`: mono 11px uppercase `.12em`. `.pw__p`: `max-width: 40ch; color: var(--muted);`. `.pw__cta`: outlined pill.
- Home: `.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: 4rem; }`; `.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); }` with a soft warm glow behind it (`::before`, a radial gradient — cheaper than a large blurred `box-shadow`) and a thin lime arc (`::after` ring clipped to its top half).
- Work: `.pw__tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; }`; `.pw__tile { aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, var(--c1) 0%, var(--c2) 100%); }` with per-tile `--c1` (`#5c3a2a`, `#2a5c48`, `#2a3d5c`) and `--c2: #0a0a0a`.
- Studio: `.pw__page--studio { grid-template-rows: 1fr auto; }`; copy `justify-self: end; text-align: right; max-width: 22ch;`; `.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); }` plus an SVG-noise grain overlay.
- Panels: `.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }`; `.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; }` (2% oversize hides sub-pixel seams; **no CSS `transform`** — GSAP would read `translateY(100%)` as 780px and the `yPercent` tween would never bring the sheet on screen; JS parks it with `gsap.set(panels, { yPercent: 100, visibility: 'visible' })`); `.pw__panel--surface { background: var(--surface, #121212); border-top: 1px solid var(--line); }`; `.pw__panel--accent { background: var(--accent, #d4ff3a); }`. DOM order = paint order: the accent panel is on top.
**Animation spec (exact values):**
1. `gsap.ticker.lagSmoothing(0)` (keeps the timeline on wall time so a heavy first paint cannot stall the curtain behind the route change). `const panels = gsap.utils.toArray('.pw__panel'); gsap.set(panels, { yPercent: 100, visibility: 'visible' });` A `busy` flag blocks re-entry; `show(name)` toggles `.is-active` on pages and links, sets `aria-current`, writes `/${name}` into `.pw__url-path` and `history.replaceState(null, '', '#' + name)`.
2. `transitionTo(name)` builds one timeline with `defaults: { ease: 'power4.inOut' }`:
- **Cover** — `.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: 0.8, stagger: 0.08 })` → surface starts at 0s, accent at 0.08s; screen fully covered at 0.8 + 0.08 seconds.
- **Swap** — `.add(() => { show(name); window.scrollTo(0, 0); gsap.set(incoming, { y: 40, opacity: 0 }); }, '+=0.02')` where `incoming = pages[name].querySelectorAll('.pw__rise')`.
- **Reveal** — `.to(panels, { yPercent: -100, duration: 0.8, stagger: -0.08 }, '+=0.05')` → negative stagger: the accent (last) panel leaves first, the surface panel 0.08s later and is the last thing to uncover the page.
- **Rise** — `.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25')` → begins 0.25s into the reveal.
- `onComplete`: `gsap.set(panels, { yPercent: 100 }); busy = false;`
3. Initial page load: no entrance tween — the first page simply renders (the rise-in belongs to transitions, so a hard refresh never replays it). Honour `location.hash` on load by calling `show()` without a transition.
4. Demo autoplay: after `document.fonts.ready`, `setTimeout(() => transitionTo('work'), 1200)` unless the user has already clicked.
5. Router hooks — the same timeline, split at the swap:
- **Astro (view transitions):** in `document.addEventListener('astro:before-preparation', (e) => { const original = e.loader; e.loader = async () => { await coverTl().then(); await original(); }; })` play the *cover* half (panels to `yPercent: 0`) and `await` it before Astro loads the next page; then in `astro:after-swap` run the *reveal* half (`yPercent: -100`, stagger `-0.08`) and the `.pw__rise` rise on the new document. Put the `.pw__panels` markup in the shared layout with `transition:persist` so the sheets survive the swap.
- **Next.js (App Router):** wrap pages in a client `TransitionProvider`; intercept `<Link>` clicks with `onClick={(e) => { e.preventDefault(); navigate(href); }}` where `navigate` = `await cover(); router.push(href); ` and in a `useEffect` keyed on `usePathname()` run `reveal()` + the rise once the new route has rendered.
- **Barba.js:** `barba.init({ transitions: [{ name: 'panel-wipe', async leave() { await cover(); }, enter({ next }) { reveal(); gsap.from(next.container.querySelectorAll('.pw__rise'), { y: 40, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.06 }); } }] })` — Barba swaps the container between `leave` and `enter`, exactly where the callback sits in the demo timeline.
**Interaction:** Any element with `data-page` (nav links, brand, the CTA) triggers `transitionTo` on click (`preventDefault`); clicks during a transition are ignored. Hovering a nav link brightens it in 0.25s; the active link grows a `#d4ff3a` underline over 0.35s. No scroll dependency.
**Responsive behavior:** Pure CSS, no `gsap.matchMedia()` needed — the timeline is identical at every size. Under 768px the nav drops the URL label (`grid-template-columns: auto 1fr`), Home stacks into one column with the orb at `min(70vw, 320px)`, Work tiles become a 2-column grid with the third tile spanning both columns at 16:9, and Studio copy left-aligns.
**Accessibility:** `prefers-reduced-motion: reduce` → `transitionTo` calls `show(name)` immediately (no panels, no rise, no autoplay). Links are real anchors with `href`, `aria-current="page"` on the active one and a visible `2px solid var(--accent)` focus ring; the panel layer and artwork are `aria-hidden`. Focus is unaffected by the transition because the nav never leaves the DOM.
**Performance rules:** Only `transform` (`yPercent`, `y`) and `opacity` animate; `will-change: transform` is on the two panels and the rising elements only, and `clearProps` removes inline styles after each rise so pages stay clean. Panels are `position: fixed` with `pointer-events: none` so they never trigger layout or hit-testing. Everything lives in `gsap.context()` and is reverted on unmount; the autoplay timer is cleared.
**Customization knobs:**
- `COVER` (0.8s) / `REVEAL` (0.8s) — sheet travel time; `STAGGER` (0.08s) — gap between the two sheets.
- Ease `'power4.inOut'` — `'expo.inOut'` for a sharper snap.
- Panel colours (`--surface`, accent `#d4ff3a`); add a third panel for a tricolour wipe (the stagger handles any count).
- Rise distance (40px) and stagger (0.06) for the incoming content.
- `AUTOPLAY_DELAY` (1.2s) — remove in production.
**Acceptance criteria:**
- [ ] Clicking a link covers the screen from the bottom with the dark sheet leading the accent one by 0.08s.
- [ ] The page content changes only while fully covered; the URL label and active underline change at the same moment.
- [ ] The accent sheet exits first through the top; the new content rises in under the lifting sheets.
- [ ] A second click mid-transition is ignored; the panels end parked at `yPercent: 100`.
- [ ] Reduced-motion users get an instant page swap with no sheets; keyboard users can tab through the nav and trigger the transition with Enter.
- [ ] No layout shift, no console errors, no visible seam between the two sheets. Add this effect to my existing site/section: **Panel Wipe Page Transition**. Add a fixed, `pointer-events: none`, `aria-hidden` `.pw__panels` layer (z-index above everything) with two `.pw__panel` sheets (`position: absolute; inset: -2% 0`), the first in my surface colour and the second in my accent colour (`#d4ff3a`), parked with `gsap.set(panels, { yPercent: 100, visibility: 'visible' })` (no CSS transform on them). On every navigation run one GSAP timeline with `defaults: { ease: 'power4.inOut' }`: `fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: 0.8, stagger: 0.08 })`, then a callback that swaps the page content (or `await`s my router — Astro `astro:before-preparation`/`astro:after-swap`, Next `router.push` inside a transition provider, or Barba `leave`/`enter`), then `to(panels, { yPercent: -100, duration: 0.8, stagger: -0.08 }, '+=0.05')` and `to(newPage.querySelectorAll('.pw__rise'), { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06 }, '<+=0.25')` from `y: 40, opacity: 0`. Block re-entry with a `busy` flag, swap instantly for `prefers-reduced-motion`, wrap in `gsap.context()` and revert on unmount. Keep my pages, colours and fonts; only add the `pw__rise` class to the elements that should rise in. ### EFFECT (React / Next.js): Panel Wipe Page Transition
Build **Panel Wipe Page Transition** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/PanelWipePageTransition.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export default function PanelWipePageTransition() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="pw">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`PanelWipePageTransition.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Panel Wipe Page Transition
**What it does (visual):** A small demo "app" with a sticky top bar (brand on the left, a fake URL `ferrule.studio/home` in the centre, Home / Work / Studio links on the right with a 1px `#d4ff3a` underline on the active one) and three full-height pages: *Home* (big serif headline "Rooms that breathe." beside a large warm glowing orb), *Work* (a headline over three tall gradient tiles) and *Studio* (right-aligned headline and address over a wide teal-to-lime gradient band). Clicking any link runs the transition: a dark surface-coloured sheet rises from the bottom of the viewport and covers it in 0.8s with the `power4.inOut` curve (accelerating then decelerating hard); an accent sheet (`#d4ff3a`) follows 0.08s behind it, so for a beat the accent is a thick stripe racing up under the dark one. The instant the screen is fully covered, the old page is replaced by the new one underneath. Then the sheets continue upward and out through the top — accent first, dark 0.08s later — over another 0.8s, and as they lift, the new page's eyebrow, headline, paragraph and artwork rise 40px into place and fade in over 1s, staggered 60ms apart. The URL label and the active link update during the covered moment. The whole gesture takes ~1.9s. On first load the demo plays one Home → Work transition by itself 1.2s after the fonts are ready, so the choreography is visible without clicking.
**Tech stack:**
- GSAP 3.13+ core only (timeline, stagger, callbacks). No plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Works standalone (hash navigation) or hooked into Astro view transitions, Next.js App Router or Barba — see below.
**HTML structure:**
```html
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work"> <!-- headline + <ul class="pw__tiles"> of 3 .pw__rise.pw__tile --> </section>
<section class="pw__page pw__page--studio" data-page="studio"> <!-- right-aligned copy + .pw__art--band --> </section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
```
Everything that should rise in on a new page carries the class `pw__rise`.
**CSS requirements:**
- `.pw`: `min-height: 100vh; display: flex; flex-direction: column;`
- `.pw__nav`: `position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: 3rem; border-bottom: 1px solid var(--line, rgba(255,255,255,.08)); background: var(--bg);`
- `.pw__link::after`: 1px `var(--accent, #d4ff3a)` underline, `transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1);` → `scaleX(1)` on `.is-active`.
- `.pw__stage`: `position: relative; flex: 1; display: grid;` — `.pw__page`: `grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: 4rem 3rem;` and `.pw__page.is-active { display: grid; }` (pages stack in the same cell; only one is displayed).
- `.pw__h`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em;`. `.pw__eyebrow`: mono 11px uppercase `.12em`. `.pw__p`: `max-width: 40ch; color: var(--muted);`. `.pw__cta`: outlined pill.
- Home: `.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: 4rem; }`; `.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); }` with a soft warm glow behind it (`::before`, a radial gradient — cheaper than a large blurred `box-shadow`) and a thin lime arc (`::after` ring clipped to its top half).
- Work: `.pw__tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; }`; `.pw__tile { aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, var(--c1) 0%, var(--c2) 100%); }` with per-tile `--c1` (`#5c3a2a`, `#2a5c48`, `#2a3d5c`) and `--c2: #0a0a0a`.
- Studio: `.pw__page--studio { grid-template-rows: 1fr auto; }`; copy `justify-self: end; text-align: right; max-width: 22ch;`; `.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); }` plus an SVG-noise grain overlay.
- Panels: `.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }`; `.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; }` (2% oversize hides sub-pixel seams; **no CSS `transform`** — GSAP would read `translateY(100%)` as 780px and the `yPercent` tween would never bring the sheet on screen; JS parks it with `gsap.set(panels, { yPercent: 100, visibility: 'visible' })`); `.pw__panel--surface { background: var(--surface, #121212); border-top: 1px solid var(--line); }`; `.pw__panel--accent { background: var(--accent, #d4ff3a); }`. DOM order = paint order: the accent panel is on top.
**Animation spec (exact values):**
1. `gsap.ticker.lagSmoothing(0)` (keeps the timeline on wall time so a heavy first paint cannot stall the curtain behind the route change). `const panels = gsap.utils.toArray('.pw__panel'); gsap.set(panels, { yPercent: 100, visibility: 'visible' });` A `busy` flag blocks re-entry; `show(name)` toggles `.is-active` on pages and links, sets `aria-current`, writes `/${name}` into `.pw__url-path` and `history.replaceState(null, '', '#' + name)`.
2. `transitionTo(name)` builds one timeline with `defaults: { ease: 'power4.inOut' }`:
- **Cover** — `.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: 0.8, stagger: 0.08 })` → surface starts at 0s, accent at 0.08s; screen fully covered at 0.8 + 0.08 seconds.
- **Swap** — `.add(() => { show(name); window.scrollTo(0, 0); gsap.set(incoming, { y: 40, opacity: 0 }); }, '+=0.02')` where `incoming = pages[name].querySelectorAll('.pw__rise')`.
- **Reveal** — `.to(panels, { yPercent: -100, duration: 0.8, stagger: -0.08 }, '+=0.05')` → negative stagger: the accent (last) panel leaves first, the surface panel 0.08s later and is the last thing to uncover the page.
- **Rise** — `.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25')` → begins 0.25s into the reveal.
- `onComplete`: `gsap.set(panels, { yPercent: 100 }); busy = false;`
3. Initial page load: no entrance tween — the first page simply renders (the rise-in belongs to transitions, so a hard refresh never replays it). Honour `location.hash` on load by calling `show()` without a transition.
4. Demo autoplay: after `document.fonts.ready`, `setTimeout(() => transitionTo('work'), 1200)` unless the user has already clicked.
5. Router hooks — the same timeline, split at the swap:
- **Astro (view transitions):** in `document.addEventListener('astro:before-preparation', (e) => { const original = e.loader; e.loader = async () => { await coverTl().then(); await original(); }; })` play the *cover* half (panels to `yPercent: 0`) and `await` it before Astro loads the next page; then in `astro:after-swap` run the *reveal* half (`yPercent: -100`, stagger `-0.08`) and the `.pw__rise` rise on the new document. Put the `.pw__panels` markup in the shared layout with `transition:persist` so the sheets survive the swap.
- **Next.js (App Router):** wrap pages in a client `TransitionProvider`; intercept `<Link>` clicks with `onClick={(e) => { e.preventDefault(); navigate(href); }}` where `navigate` = `await cover(); router.push(href); ` and in a `useEffect` keyed on `usePathname()` run `reveal()` + the rise once the new route has rendered.
- **Barba.js:** `barba.init({ transitions: [{ name: 'panel-wipe', async leave() { await cover(); }, enter({ next }) { reveal(); gsap.from(next.container.querySelectorAll('.pw__rise'), { y: 40, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.06 }); } }] })` — Barba swaps the container between `leave` and `enter`, exactly where the callback sits in the demo timeline.
**Interaction:** Any element with `data-page` (nav links, brand, the CTA) triggers `transitionTo` on click (`preventDefault`); clicks during a transition are ignored. Hovering a nav link brightens it in 0.25s; the active link grows a `#d4ff3a` underline over 0.35s. No scroll dependency.
**Responsive behavior:** Pure CSS, no `gsap.matchMedia()` needed — the timeline is identical at every size. Under 768px the nav drops the URL label (`grid-template-columns: auto 1fr`), Home stacks into one column with the orb at `min(70vw, 320px)`, Work tiles become a 2-column grid with the third tile spanning both columns at 16:9, and Studio copy left-aligns.
**Accessibility:** `prefers-reduced-motion: reduce` → `transitionTo` calls `show(name)` immediately (no panels, no rise, no autoplay). Links are real anchors with `href`, `aria-current="page"` on the active one and a visible `2px solid var(--accent)` focus ring; the panel layer and artwork are `aria-hidden`. Focus is unaffected by the transition because the nav never leaves the DOM.
**Performance rules:** Only `transform` (`yPercent`, `y`) and `opacity` animate; `will-change: transform` is on the two panels and the rising elements only, and `clearProps` removes inline styles after each rise so pages stay clean. Panels are `position: fixed` with `pointer-events: none` so they never trigger layout or hit-testing. Everything lives in `gsap.context()` and is reverted on unmount; the autoplay timer is cleared.
**Customization knobs:**
- `COVER` (0.8s) / `REVEAL` (0.8s) — sheet travel time; `STAGGER` (0.08s) — gap between the two sheets.
- Ease `'power4.inOut'` — `'expo.inOut'` for a sharper snap.
- Panel colours (`--surface`, accent `#d4ff3a`); add a third panel for a tricolour wipe (the stagger handles any count).
- Rise distance (40px) and stagger (0.06) for the incoming content.
- `AUTOPLAY_DELAY` (1.2s) — remove in production.
**Acceptance criteria:**
- [ ] Clicking a link covers the screen from the bottom with the dark sheet leading the accent one by 0.08s.
- [ ] The page content changes only while fully covered; the URL label and active underline change at the same moment.
- [ ] The accent sheet exits first through the top; the new content rises in under the lifting sheets.
- [ ] A second click mid-transition is ignored; the panels end parked at `yPercent: 100`.
- [ ] Reduced-motion users get an instant page swap with no sheets; keyboard users can tab through the nav and trigger the transition with Enter.
- [ ] No layout shift, no console errors, no visible seam between the two sheets.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links" aria-label="Demo pages">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs by people who will still answer the phone in ten years.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work">
<div class="pw__work-head">
<p class="pw__rise pw__eyebrow">Selected work</p>
<h1 class="pw__rise pw__h">Three houses, one <em>grain</em>.</h1>
</div>
<ul class="pw__tiles" role="list">
<li class="pw__rise pw__tile" style="--c1:#5c3a2a;--c2:#0a0a0a"><span class="pw__tile-name">Linden House</span><span class="pw__tile-meta">Dining · 2026</span></li>
<li class="pw__rise pw__tile" style="--c1:#2a5c48;--c2:#0a0a0a"><span class="pw__tile-name">Ash Hall</span><span class="pw__tile-meta">Library · 2025</span></li>
<li class="pw__rise pw__tile" style="--c1:#2a3d5c;--c2:#0a0a0a"><span class="pw__tile-name">Court Studio</span><span class="pw__tile-meta">Workspace · 2025</span></li>
</ul>
</section>
<section class="pw__page pw__page--studio" data-page="studio">
<div class="pw__studio-copy">
<p class="pw__rise pw__eyebrow">Studio</p>
<h1 class="pw__rise pw__h">A workshop in Lisbon, a desk in <em>Oslo</em>.</h1>
<address class="pw__rise pw__address">Rua da Boavista 84 · Lisboa<br>Tordenskiolds gate 3 · Oslo<br>hello@ferrule.studio</address>
</div>
<div class="pw__rise pw__art pw__art--band" aria-hidden="true"></div>
</section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
```
```css
.pw { min-height: 100vh; display: flex; flex-direction: column; }
.pw__nav { position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: clamp(1.25rem, 4vw, 3rem); background: var(--bg); border-bottom: 1px solid var(--line); }
.pw__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.15rem; letter-spacing: -0.02em; text-decoration: none; }
.pw__url { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .04em; }
.pw__url-path { color: var(--text); }
.pw__links { display: flex; justify-content: flex-end; gap: clamp(1rem, 2.5vw, 2rem); }
.pw__link { position: relative; font-size: .85rem; color: var(--muted); text-decoration: none; padding: .4rem 0; transition: color .25s; }
.pw__link::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--knob-accent, var(--accent)); transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1); }
.pw__link:hover, .pw__link.is-active { color: var(--text); }
.pw__link.is-active::after { transform: scaleX(1); }
.pw a:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 4px; }
.pw__stage { position: relative; flex: 1; display: grid; }
.pw__page { grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: clamp(1.5rem, 5vw, 4rem) clamp(1.25rem, 4vw, 3rem); }
.pw__page.is-active { display: grid; }
.pw__rise { will-change: transform, opacity; }
.pw__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 1.25rem; }
.pw__h { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; text-wrap: balance; }
.pw__h em { font-style: italic; }
.pw__p { max-width: 40ch; margin-top: 1.5rem; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.5; }
.pw__cta { display: inline-block; margin-top: 2.25rem; padding: .75em 1.5em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; font-size: .85rem; font-weight: 500; text-decoration: none; transition: background .25s, color .25s, border-color .25s; }
.pw__cta:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
/* Home: copy + orb */
.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 4rem); }
.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; margin-inline: auto; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); position: relative; }
.pw__art--orb::before { content: ''; position: absolute; inset: -12% -18% -28%; border-radius: 50%; background: radial-gradient(ellipse at 50% 60%, rgba(233,179,106,.28) 0%, rgba(233,179,106,0) 62%); z-index: -1; }
.pw__art--orb::after { content: ''; position: absolute; inset: -6%; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); clip-path: inset(0 0 55% 0); }
/* Work: headline + tiles */
.pw__page--work { grid-template-rows: auto 1fr; align-content: start; gap: clamp(2rem, 4vw, 3rem); }
.pw__tiles { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(.75rem, 1.5vw, 1.25rem); align-self: end; }
.pw__tile { position: relative; aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; padding: 1.25rem; color: #fff; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, color-mix(in srgb, var(--c1) 60%, #fff 0%) 0%, var(--c2) 100%); }
.pw__tile::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 42px); }
.pw__tile-name { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.4vw, 2.2rem); letter-spacing: -0.03em; line-height: 1; }
.pw__tile-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; opacity: .7; margin-top: .5rem; }
/* Studio: right-aligned copy + gradient band */
.pw__page--studio { grid-template-rows: 1fr auto; gap: clamp(2rem, 4vw, 3rem); }
.pw__studio-copy { justify-self: end; text-align: right; max-width: 22ch; align-self: center; }
.pw__studio-copy .pw__h { font-size: clamp(2.4rem, 5.6vw, 5.4rem); }
.pw__address { font-style: normal; font-family: var(--font-mono); font-size: 12px; line-height: 1.8; color: var(--muted); margin-top: 2rem; letter-spacing: .02em; }
.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); position: relative; overflow: hidden; }
.pw__art--band::after { content: ''; position: absolute; inset: 0; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .2 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); background-size: 160px; mix-blend-mode: overlay; opacity: .6; }
/* The curtains */
.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }
.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; } /* parked at yPercent: 100 by JS — no CSS transform, or GSAP would bake it in as pixels */
.pw__panel--surface { background: var(--surface); border-top: 1px solid var(--line); }
.pw__panel--accent { background: var(--knob-accent, var(--accent)); }
@media (max-width: 767px) {
.pw__nav { grid-template-columns: auto 1fr; }
.pw__url { display: none; }
.pw__page--home { grid-template-columns: 1fr; align-content: start; }
.pw__art--orb { width: min(70vw, 320px); }
.pw__tiles { grid-template-columns: 1fr 1fr; }
.pw__tiles .pw__tile:last-child { grid-column: span 2; aspect-ratio: 16 / 9; }
.pw__studio-copy { justify-self: start; text-align: left; max-width: none; }
}
@media (prefers-reduced-motion: reduce) {
.pw__rise, .pw__panel { will-change: auto; }
.pw__link::after { transition: none; }
}
```
```js
// Panel Wipe Page Transition — two sheets wipe up over the page, the content swaps beneath them, they exit through the top.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { cover: 0.8, reveal: 0.8, stagger: 0.08, ease: 'power4.inOut', rise: 40, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const AUTOPLAY_DELAY = 1.2; // demo-only: play one transition after the fonts are in, so the choreography is visible without a click
// Keep GSAP's clock on wall time: a heavy first paint must never make the curtain lag behind the route change.
gsap.ticker.lagSmoothing(0);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.pw');
const pages = Object.fromEntries(gsap.utils.toArray('.pw__page').map((p) => [p.dataset.page, p]));
const order = Object.keys(pages);
const links = gsap.utils.toArray('.pw__link');
const urlPath = document.querySelector('.pw__url-path');
const panels = gsap.utils.toArray('.pw__panel'); // [surface, accent] — accent is on top
let current = 'home';
let busy = false;
let interacted = false;
let autoTimer;
function show(name) {
Object.values(pages).forEach((p) => p.classList.toggle('is-active', p.dataset.page === name));
links.forEach((a) => {
const active = a.dataset.page === name;
a.classList.toggle('is-active', active);
active ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current');
});
urlPath.textContent = `/${name}`;
history.replaceState(null, '', `#${name}`);
current = name;
}
// honour a deep link on load
const initial = location.hash.slice(1);
if (pages[initial]) show(initial);
let ctx;
let transitionTo = () => {};
function build(e) {
const fromKnobs = e?.type === 'ml:knobs';
clearTimeout(autoTimer);
ctx?.revert(); // kills any running timeline and restores the inline styles it set
gsap.set('.pw__rise', { clearProps: 'transform,opacity' }); // a rebuild mid-rise must never leave content parked invisible
busy = false;
root.style.setProperty('--knob-accent', K('accent'));
const COVER = K('cover');
const REVEAL = K('reveal');
const STAGGER = K('stagger');
const EASE = K('ease');
const RISE = K('rise');
ctx = gsap.context(() => {
gsap.set(panels, { yPercent: 100, visibility: 'visible' }); // park below the viewport (CSS keeps them hidden until this runs)
transitionTo = (name) => {
if (busy || !pages[name] || name === current) return;
busy = true;
if (reduced) { show(name); busy = false; return; }
const incoming = pages[name].querySelectorAll('.pw__rise');
gsap.timeline({ defaults: { ease: EASE }, onComplete: () => { gsap.set(panels, { yPercent: 100 }); busy = false; } })
// 1. cover: surface first, accent STAGGER behind
.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: COVER, stagger: STAGGER })
// 2. swap the page while fully covered
.add(() => {
show(name);
window.scrollTo(0, 0);
gsap.set(incoming, { y: RISE, opacity: 0 });
}, '+=0.02')
// 3. reveal: accent leaves first (negative stagger), surface is the last thing to uncover the page
.to(panels, { yPercent: -100, duration: REVEAL, stagger: -STAGGER }, '+=0.05')
// 4. the new content rises in under the lifting panels
.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25');
};
const onClick = (e) => {
const a = e.target.closest('[data-page]');
if (!a) return;
e.preventDefault();
interacted = true;
clearTimeout(autoTimer);
transitionTo(a.dataset.page);
};
root.addEventListener('click', onClick);
if (!reduced) {
if (fromKnobs) {
// Playground: show the new values straight away by moving to the next page
autoTimer = setTimeout(() => transitionTo(order[(order.indexOf(current) + 1) % order.length]), 150);
} else {
// Demo autoplay: once the fonts are in, play one Home → Work so the choreography is visible without a click.
document.fonts.ready.then(() => {
autoTimer = setTimeout(() => { if (!interacted && current === 'home') transitionTo('work'); }, AUTOPLAY_DELAY * 1000);
});
}
}
return () => { root.removeEventListener('click', onClick); clearTimeout(autoTimer); };
});
}
build();
// Playground: rebuild with the new values (and play one transition so they are visible)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Panel Wipe Page Transition
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Panel Wipe Page Transition** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Panel Wipe Page Transition</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Panel Wipe Page Transition
**What it does (visual):** A small demo "app" with a sticky top bar (brand on the left, a fake URL `ferrule.studio/home` in the centre, Home / Work / Studio links on the right with a 1px `#d4ff3a` underline on the active one) and three full-height pages: *Home* (big serif headline "Rooms that breathe." beside a large warm glowing orb), *Work* (a headline over three tall gradient tiles) and *Studio* (right-aligned headline and address over a wide teal-to-lime gradient band). Clicking any link runs the transition: a dark surface-coloured sheet rises from the bottom of the viewport and covers it in 0.8s with the `power4.inOut` curve (accelerating then decelerating hard); an accent sheet (`#d4ff3a`) follows 0.08s behind it, so for a beat the accent is a thick stripe racing up under the dark one. The instant the screen is fully covered, the old page is replaced by the new one underneath. Then the sheets continue upward and out through the top — accent first, dark 0.08s later — over another 0.8s, and as they lift, the new page's eyebrow, headline, paragraph and artwork rise 40px into place and fade in over 1s, staggered 60ms apart. The URL label and the active link update during the covered moment. The whole gesture takes ~1.9s. On first load the demo plays one Home → Work transition by itself 1.2s after the fonts are ready, so the choreography is visible without clicking.
**Tech stack:**
- GSAP 3.13+ core only (timeline, stagger, callbacks). No plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Works standalone (hash navigation) or hooked into Astro view transitions, Next.js App Router or Barba — see below.
**HTML structure:**
```html
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work"> <!-- headline + <ul class="pw__tiles"> of 3 .pw__rise.pw__tile --> </section>
<section class="pw__page pw__page--studio" data-page="studio"> <!-- right-aligned copy + .pw__art--band --> </section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
```
Everything that should rise in on a new page carries the class `pw__rise`.
**CSS requirements:**
- `.pw`: `min-height: 100vh; display: flex; flex-direction: column;`
- `.pw__nav`: `position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: 3rem; border-bottom: 1px solid var(--line, rgba(255,255,255,.08)); background: var(--bg);`
- `.pw__link::after`: 1px `var(--accent, #d4ff3a)` underline, `transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1);` → `scaleX(1)` on `.is-active`.
- `.pw__stage`: `position: relative; flex: 1; display: grid;` — `.pw__page`: `grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: 4rem 3rem;` and `.pw__page.is-active { display: grid; }` (pages stack in the same cell; only one is displayed).
- `.pw__h`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em;`. `.pw__eyebrow`: mono 11px uppercase `.12em`. `.pw__p`: `max-width: 40ch; color: var(--muted);`. `.pw__cta`: outlined pill.
- Home: `.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: 4rem; }`; `.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); }` with a soft warm glow behind it (`::before`, a radial gradient — cheaper than a large blurred `box-shadow`) and a thin lime arc (`::after` ring clipped to its top half).
- Work: `.pw__tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; }`; `.pw__tile { aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, var(--c1) 0%, var(--c2) 100%); }` with per-tile `--c1` (`#5c3a2a`, `#2a5c48`, `#2a3d5c`) and `--c2: #0a0a0a`.
- Studio: `.pw__page--studio { grid-template-rows: 1fr auto; }`; copy `justify-self: end; text-align: right; max-width: 22ch;`; `.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); }` plus an SVG-noise grain overlay.
- Panels: `.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }`; `.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; }` (2% oversize hides sub-pixel seams; **no CSS `transform`** — GSAP would read `translateY(100%)` as 780px and the `yPercent` tween would never bring the sheet on screen; JS parks it with `gsap.set(panels, { yPercent: 100, visibility: 'visible' })`); `.pw__panel--surface { background: var(--surface, #121212); border-top: 1px solid var(--line); }`; `.pw__panel--accent { background: var(--accent, #d4ff3a); }`. DOM order = paint order: the accent panel is on top.
**Animation spec (exact values):**
1. `gsap.ticker.lagSmoothing(0)` (keeps the timeline on wall time so a heavy first paint cannot stall the curtain behind the route change). `const panels = gsap.utils.toArray('.pw__panel'); gsap.set(panels, { yPercent: 100, visibility: 'visible' });` A `busy` flag blocks re-entry; `show(name)` toggles `.is-active` on pages and links, sets `aria-current`, writes `/${name}` into `.pw__url-path` and `history.replaceState(null, '', '#' + name)`.
2. `transitionTo(name)` builds one timeline with `defaults: { ease: 'power4.inOut' }`:
- **Cover** — `.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: 0.8, stagger: 0.08 })` → surface starts at 0s, accent at 0.08s; screen fully covered at 0.8 + 0.08 seconds.
- **Swap** — `.add(() => { show(name); window.scrollTo(0, 0); gsap.set(incoming, { y: 40, opacity: 0 }); }, '+=0.02')` where `incoming = pages[name].querySelectorAll('.pw__rise')`.
- **Reveal** — `.to(panels, { yPercent: -100, duration: 0.8, stagger: -0.08 }, '+=0.05')` → negative stagger: the accent (last) panel leaves first, the surface panel 0.08s later and is the last thing to uncover the page.
- **Rise** — `.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25')` → begins 0.25s into the reveal.
- `onComplete`: `gsap.set(panels, { yPercent: 100 }); busy = false;`
3. Initial page load: no entrance tween — the first page simply renders (the rise-in belongs to transitions, so a hard refresh never replays it). Honour `location.hash` on load by calling `show()` without a transition.
4. Demo autoplay: after `document.fonts.ready`, `setTimeout(() => transitionTo('work'), 1200)` unless the user has already clicked.
5. Router hooks — the same timeline, split at the swap:
- **Astro (view transitions):** in `document.addEventListener('astro:before-preparation', (e) => { const original = e.loader; e.loader = async () => { await coverTl().then(); await original(); }; })` play the *cover* half (panels to `yPercent: 0`) and `await` it before Astro loads the next page; then in `astro:after-swap` run the *reveal* half (`yPercent: -100`, stagger `-0.08`) and the `.pw__rise` rise on the new document. Put the `.pw__panels` markup in the shared layout with `transition:persist` so the sheets survive the swap.
- **Next.js (App Router):** wrap pages in a client `TransitionProvider`; intercept `<Link>` clicks with `onClick={(e) => { e.preventDefault(); navigate(href); }}` where `navigate` = `await cover(); router.push(href); ` and in a `useEffect` keyed on `usePathname()` run `reveal()` + the rise once the new route has rendered.
- **Barba.js:** `barba.init({ transitions: [{ name: 'panel-wipe', async leave() { await cover(); }, enter({ next }) { reveal(); gsap.from(next.container.querySelectorAll('.pw__rise'), { y: 40, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.06 }); } }] })` — Barba swaps the container between `leave` and `enter`, exactly where the callback sits in the demo timeline.
**Interaction:** Any element with `data-page` (nav links, brand, the CTA) triggers `transitionTo` on click (`preventDefault`); clicks during a transition are ignored. Hovering a nav link brightens it in 0.25s; the active link grows a `#d4ff3a` underline over 0.35s. No scroll dependency.
**Responsive behavior:** Pure CSS, no `gsap.matchMedia()` needed — the timeline is identical at every size. Under 768px the nav drops the URL label (`grid-template-columns: auto 1fr`), Home stacks into one column with the orb at `min(70vw, 320px)`, Work tiles become a 2-column grid with the third tile spanning both columns at 16:9, and Studio copy left-aligns.
**Accessibility:** `prefers-reduced-motion: reduce` → `transitionTo` calls `show(name)` immediately (no panels, no rise, no autoplay). Links are real anchors with `href`, `aria-current="page"` on the active one and a visible `2px solid var(--accent)` focus ring; the panel layer and artwork are `aria-hidden`. Focus is unaffected by the transition because the nav never leaves the DOM.
**Performance rules:** Only `transform` (`yPercent`, `y`) and `opacity` animate; `will-change: transform` is on the two panels and the rising elements only, and `clearProps` removes inline styles after each rise so pages stay clean. Panels are `position: fixed` with `pointer-events: none` so they never trigger layout or hit-testing. Everything lives in `gsap.context()` and is reverted on unmount; the autoplay timer is cleared.
**Customization knobs:**
- `COVER` (0.8s) / `REVEAL` (0.8s) — sheet travel time; `STAGGER` (0.08s) — gap between the two sheets.
- Ease `'power4.inOut'` — `'expo.inOut'` for a sharper snap.
- Panel colours (`--surface`, accent `#d4ff3a`); add a third panel for a tricolour wipe (the stagger handles any count).
- Rise distance (40px) and stagger (0.06) for the incoming content.
- `AUTOPLAY_DELAY` (1.2s) — remove in production.
**Acceptance criteria:**
- [ ] Clicking a link covers the screen from the bottom with the dark sheet leading the accent one by 0.08s.
- [ ] The page content changes only while fully covered; the URL label and active underline change at the same moment.
- [ ] The accent sheet exits first through the top; the new content rises in under the lifting sheets.
- [ ] A second click mid-transition is ignored; the panels end parked at `yPercent: 100`.
- [ ] Reduced-motion users get an instant page swap with no sheets; keyboard users can tab through the nav and trigger the transition with Enter.
- [ ] No layout shift, no console errors, no visible seam between the two sheets.
---
**Reference implementation (use these exact values and class names):**
```html
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links" aria-label="Demo pages">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs by people who will still answer the phone in ten years.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work">
<div class="pw__work-head">
<p class="pw__rise pw__eyebrow">Selected work</p>
<h1 class="pw__rise pw__h">Three houses, one <em>grain</em>.</h1>
</div>
<ul class="pw__tiles" role="list">
<li class="pw__rise pw__tile" style="--c1:#5c3a2a;--c2:#0a0a0a"><span class="pw__tile-name">Linden House</span><span class="pw__tile-meta">Dining · 2026</span></li>
<li class="pw__rise pw__tile" style="--c1:#2a5c48;--c2:#0a0a0a"><span class="pw__tile-name">Ash Hall</span><span class="pw__tile-meta">Library · 2025</span></li>
<li class="pw__rise pw__tile" style="--c1:#2a3d5c;--c2:#0a0a0a"><span class="pw__tile-name">Court Studio</span><span class="pw__tile-meta">Workspace · 2025</span></li>
</ul>
</section>
<section class="pw__page pw__page--studio" data-page="studio">
<div class="pw__studio-copy">
<p class="pw__rise pw__eyebrow">Studio</p>
<h1 class="pw__rise pw__h">A workshop in Lisbon, a desk in <em>Oslo</em>.</h1>
<address class="pw__rise pw__address">Rua da Boavista 84 · Lisboa<br>Tordenskiolds gate 3 · Oslo<br>hello@ferrule.studio</address>
</div>
<div class="pw__rise pw__art pw__art--band" aria-hidden="true"></div>
</section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
```
```css
.pw { min-height: 100vh; display: flex; flex-direction: column; }
.pw__nav { position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: clamp(1.25rem, 4vw, 3rem); background: var(--bg); border-bottom: 1px solid var(--line); }
.pw__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.15rem; letter-spacing: -0.02em; text-decoration: none; }
.pw__url { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .04em; }
.pw__url-path { color: var(--text); }
.pw__links { display: flex; justify-content: flex-end; gap: clamp(1rem, 2.5vw, 2rem); }
.pw__link { position: relative; font-size: .85rem; color: var(--muted); text-decoration: none; padding: .4rem 0; transition: color .25s; }
.pw__link::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--knob-accent, var(--accent)); transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1); }
.pw__link:hover, .pw__link.is-active { color: var(--text); }
.pw__link.is-active::after { transform: scaleX(1); }
.pw a:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 4px; }
.pw__stage { position: relative; flex: 1; display: grid; }
.pw__page { grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: clamp(1.5rem, 5vw, 4rem) clamp(1.25rem, 4vw, 3rem); }
.pw__page.is-active { display: grid; }
.pw__rise { will-change: transform, opacity; }
.pw__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 1.25rem; }
.pw__h { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; text-wrap: balance; }
.pw__h em { font-style: italic; }
.pw__p { max-width: 40ch; margin-top: 1.5rem; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.5; }
.pw__cta { display: inline-block; margin-top: 2.25rem; padding: .75em 1.5em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; font-size: .85rem; font-weight: 500; text-decoration: none; transition: background .25s, color .25s, border-color .25s; }
.pw__cta:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
/* Home: copy + orb */
.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 4rem); }
.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; margin-inline: auto; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); position: relative; }
.pw__art--orb::before { content: ''; position: absolute; inset: -12% -18% -28%; border-radius: 50%; background: radial-gradient(ellipse at 50% 60%, rgba(233,179,106,.28) 0%, rgba(233,179,106,0) 62%); z-index: -1; }
.pw__art--orb::after { content: ''; position: absolute; inset: -6%; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); clip-path: inset(0 0 55% 0); }
/* Work: headline + tiles */
.pw__page--work { grid-template-rows: auto 1fr; align-content: start; gap: clamp(2rem, 4vw, 3rem); }
.pw__tiles { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(.75rem, 1.5vw, 1.25rem); align-self: end; }
.pw__tile { position: relative; aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; padding: 1.25rem; color: #fff; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, color-mix(in srgb, var(--c1) 60%, #fff 0%) 0%, var(--c2) 100%); }
.pw__tile::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 42px); }
.pw__tile-name { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.4vw, 2.2rem); letter-spacing: -0.03em; line-height: 1; }
.pw__tile-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; opacity: .7; margin-top: .5rem; }
/* Studio: right-aligned copy + gradient band */
.pw__page--studio { grid-template-rows: 1fr auto; gap: clamp(2rem, 4vw, 3rem); }
.pw__studio-copy { justify-self: end; text-align: right; max-width: 22ch; align-self: center; }
.pw__studio-copy .pw__h { font-size: clamp(2.4rem, 5.6vw, 5.4rem); }
.pw__address { font-style: normal; font-family: var(--font-mono); font-size: 12px; line-height: 1.8; color: var(--muted); margin-top: 2rem; letter-spacing: .02em; }
.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); position: relative; overflow: hidden; }
.pw__art--band::after { content: ''; position: absolute; inset: 0; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .2 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); background-size: 160px; mix-blend-mode: overlay; opacity: .6; }
/* The curtains */
.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }
.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; } /* parked at yPercent: 100 by JS — no CSS transform, or GSAP would bake it in as pixels */
.pw__panel--surface { background: var(--surface); border-top: 1px solid var(--line); }
.pw__panel--accent { background: var(--knob-accent, var(--accent)); }
@media (max-width: 767px) {
.pw__nav { grid-template-columns: auto 1fr; }
.pw__url { display: none; }
.pw__page--home { grid-template-columns: 1fr; align-content: start; }
.pw__art--orb { width: min(70vw, 320px); }
.pw__tiles { grid-template-columns: 1fr 1fr; }
.pw__tiles .pw__tile:last-child { grid-column: span 2; aspect-ratio: 16 / 9; }
.pw__studio-copy { justify-self: start; text-align: left; max-width: none; }
}
@media (prefers-reduced-motion: reduce) {
.pw__rise, .pw__panel { will-change: auto; }
.pw__link::after { transition: none; }
}
```
```js
// Panel Wipe Page Transition — two sheets wipe up over the page, the content swaps beneath them, they exit through the top.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { cover: 0.8, reveal: 0.8, stagger: 0.08, ease: 'power4.inOut', rise: 40, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const AUTOPLAY_DELAY = 1.2; // demo-only: play one transition after the fonts are in, so the choreography is visible without a click
// Keep GSAP's clock on wall time: a heavy first paint must never make the curtain lag behind the route change.
gsap.ticker.lagSmoothing(0);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.pw');
const pages = Object.fromEntries(gsap.utils.toArray('.pw__page').map((p) => [p.dataset.page, p]));
const order = Object.keys(pages);
const links = gsap.utils.toArray('.pw__link');
const urlPath = document.querySelector('.pw__url-path');
const panels = gsap.utils.toArray('.pw__panel'); // [surface, accent] — accent is on top
let current = 'home';
let busy = false;
let interacted = false;
let autoTimer;
function show(name) {
Object.values(pages).forEach((p) => p.classList.toggle('is-active', p.dataset.page === name));
links.forEach((a) => {
const active = a.dataset.page === name;
a.classList.toggle('is-active', active);
active ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current');
});
urlPath.textContent = `/${name}`;
history.replaceState(null, '', `#${name}`);
current = name;
}
// honour a deep link on load
const initial = location.hash.slice(1);
if (pages[initial]) show(initial);
let ctx;
let transitionTo = () => {};
function build(e) {
const fromKnobs = e?.type === 'ml:knobs';
clearTimeout(autoTimer);
ctx?.revert(); // kills any running timeline and restores the inline styles it set
gsap.set('.pw__rise', { clearProps: 'transform,opacity' }); // a rebuild mid-rise must never leave content parked invisible
busy = false;
root.style.setProperty('--knob-accent', K('accent'));
const COVER = K('cover');
const REVEAL = K('reveal');
const STAGGER = K('stagger');
const EASE = K('ease');
const RISE = K('rise');
ctx = gsap.context(() => {
gsap.set(panels, { yPercent: 100, visibility: 'visible' }); // park below the viewport (CSS keeps them hidden until this runs)
transitionTo = (name) => {
if (busy || !pages[name] || name === current) return;
busy = true;
if (reduced) { show(name); busy = false; return; }
const incoming = pages[name].querySelectorAll('.pw__rise');
gsap.timeline({ defaults: { ease: EASE }, onComplete: () => { gsap.set(panels, { yPercent: 100 }); busy = false; } })
// 1. cover: surface first, accent STAGGER behind
.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: COVER, stagger: STAGGER })
// 2. swap the page while fully covered
.add(() => {
show(name);
window.scrollTo(0, 0);
gsap.set(incoming, { y: RISE, opacity: 0 });
}, '+=0.02')
// 3. reveal: accent leaves first (negative stagger), surface is the last thing to uncover the page
.to(panels, { yPercent: -100, duration: REVEAL, stagger: -STAGGER }, '+=0.05')
// 4. the new content rises in under the lifting panels
.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25');
};
const onClick = (e) => {
const a = e.target.closest('[data-page]');
if (!a) return;
e.preventDefault();
interacted = true;
clearTimeout(autoTimer);
transitionTo(a.dataset.page);
};
root.addEventListener('click', onClick);
if (!reduced) {
if (fromKnobs) {
// Playground: show the new values straight away by moving to the next page
autoTimer = setTimeout(() => transitionTo(order[(order.indexOf(current) + 1) % order.length]), 150);
} else {
// Demo autoplay: once the fonts are in, play one Home → Work so the choreography is visible without a click.
document.fonts.ready.then(() => {
autoTimer = setTimeout(() => { if (!interacted && current === 'home') transitionTo('work'); }, AUTOPLAY_DELAY * 1000);
});
}
}
return () => { root.removeEventListener('click', onClick); clearTimeout(autoTimer); };
});
}
build();
// Playground: rebuild with the new values (and play one transition so they are visible)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Panel Wipe Page Transition
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withPanelWipePageTransition(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `pw`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Panel Wipe Page Transition
**What it does (visual):** A small demo "app" with a sticky top bar (brand on the left, a fake URL `ferrule.studio/home` in the centre, Home / Work / Studio links on the right with a 1px `#d4ff3a` underline on the active one) and three full-height pages: *Home* (big serif headline "Rooms that breathe." beside a large warm glowing orb), *Work* (a headline over three tall gradient tiles) and *Studio* (right-aligned headline and address over a wide teal-to-lime gradient band). Clicking any link runs the transition: a dark surface-coloured sheet rises from the bottom of the viewport and covers it in 0.8s with the `power4.inOut` curve (accelerating then decelerating hard); an accent sheet (`#d4ff3a`) follows 0.08s behind it, so for a beat the accent is a thick stripe racing up under the dark one. The instant the screen is fully covered, the old page is replaced by the new one underneath. Then the sheets continue upward and out through the top — accent first, dark 0.08s later — over another 0.8s, and as they lift, the new page's eyebrow, headline, paragraph and artwork rise 40px into place and fade in over 1s, staggered 60ms apart. The URL label and the active link update during the covered moment. The whole gesture takes ~1.9s. On first load the demo plays one Home → Work transition by itself 1.2s after the fonts are ready, so the choreography is visible without clicking.
**Tech stack:**
- GSAP 3.13+ core only (timeline, stagger, callbacks). No plugins.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Works standalone (hash navigation) or hooked into Astro view transitions, Next.js App Router or Barba — see below.
**HTML structure:**
```html
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work"> <!-- headline + <ul class="pw__tiles"> of 3 .pw__rise.pw__tile --> </section>
<section class="pw__page pw__page--studio" data-page="studio"> <!-- right-aligned copy + .pw__art--band --> </section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
```
Everything that should rise in on a new page carries the class `pw__rise`.
**CSS requirements:**
- `.pw`: `min-height: 100vh; display: flex; flex-direction: column;`
- `.pw__nav`: `position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: 3rem; border-bottom: 1px solid var(--line, rgba(255,255,255,.08)); background: var(--bg);`
- `.pw__link::after`: 1px `var(--accent, #d4ff3a)` underline, `transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1);` → `scaleX(1)` on `.is-active`.
- `.pw__stage`: `position: relative; flex: 1; display: grid;` — `.pw__page`: `grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: 4rem 3rem;` and `.pw__page.is-active { display: grid; }` (pages stack in the same cell; only one is displayed).
- `.pw__h`: display serif, `font-weight: 300; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em;`. `.pw__eyebrow`: mono 11px uppercase `.12em`. `.pw__p`: `max-width: 40ch; color: var(--muted);`. `.pw__cta`: outlined pill.
- Home: `.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: 4rem; }`; `.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); }` with a soft warm glow behind it (`::before`, a radial gradient — cheaper than a large blurred `box-shadow`) and a thin lime arc (`::after` ring clipped to its top half).
- Work: `.pw__tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; }`; `.pw__tile { aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, var(--c1) 0%, var(--c2) 100%); }` with per-tile `--c1` (`#5c3a2a`, `#2a5c48`, `#2a3d5c`) and `--c2: #0a0a0a`.
- Studio: `.pw__page--studio { grid-template-rows: 1fr auto; }`; copy `justify-self: end; text-align: right; max-width: 22ch;`; `.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); }` plus an SVG-noise grain overlay.
- Panels: `.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }`; `.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; }` (2% oversize hides sub-pixel seams; **no CSS `transform`** — GSAP would read `translateY(100%)` as 780px and the `yPercent` tween would never bring the sheet on screen; JS parks it with `gsap.set(panels, { yPercent: 100, visibility: 'visible' })`); `.pw__panel--surface { background: var(--surface, #121212); border-top: 1px solid var(--line); }`; `.pw__panel--accent { background: var(--accent, #d4ff3a); }`. DOM order = paint order: the accent panel is on top.
**Animation spec (exact values):**
1. `gsap.ticker.lagSmoothing(0)` (keeps the timeline on wall time so a heavy first paint cannot stall the curtain behind the route change). `const panels = gsap.utils.toArray('.pw__panel'); gsap.set(panels, { yPercent: 100, visibility: 'visible' });` A `busy` flag blocks re-entry; `show(name)` toggles `.is-active` on pages and links, sets `aria-current`, writes `/${name}` into `.pw__url-path` and `history.replaceState(null, '', '#' + name)`.
2. `transitionTo(name)` builds one timeline with `defaults: { ease: 'power4.inOut' }`:
- **Cover** — `.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: 0.8, stagger: 0.08 })` → surface starts at 0s, accent at 0.08s; screen fully covered at 0.8 + 0.08 seconds.
- **Swap** — `.add(() => { show(name); window.scrollTo(0, 0); gsap.set(incoming, { y: 40, opacity: 0 }); }, '+=0.02')` where `incoming = pages[name].querySelectorAll('.pw__rise')`.
- **Reveal** — `.to(panels, { yPercent: -100, duration: 0.8, stagger: -0.08 }, '+=0.05')` → negative stagger: the accent (last) panel leaves first, the surface panel 0.08s later and is the last thing to uncover the page.
- **Rise** — `.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25')` → begins 0.25s into the reveal.
- `onComplete`: `gsap.set(panels, { yPercent: 100 }); busy = false;`
3. Initial page load: no entrance tween — the first page simply renders (the rise-in belongs to transitions, so a hard refresh never replays it). Honour `location.hash` on load by calling `show()` without a transition.
4. Demo autoplay: after `document.fonts.ready`, `setTimeout(() => transitionTo('work'), 1200)` unless the user has already clicked.
5. Router hooks — the same timeline, split at the swap:
- **Astro (view transitions):** in `document.addEventListener('astro:before-preparation', (e) => { const original = e.loader; e.loader = async () => { await coverTl().then(); await original(); }; })` play the *cover* half (panels to `yPercent: 0`) and `await` it before Astro loads the next page; then in `astro:after-swap` run the *reveal* half (`yPercent: -100`, stagger `-0.08`) and the `.pw__rise` rise on the new document. Put the `.pw__panels` markup in the shared layout with `transition:persist` so the sheets survive the swap.
- **Next.js (App Router):** wrap pages in a client `TransitionProvider`; intercept `<Link>` clicks with `onClick={(e) => { e.preventDefault(); navigate(href); }}` where `navigate` = `await cover(); router.push(href); ` and in a `useEffect` keyed on `usePathname()` run `reveal()` + the rise once the new route has rendered.
- **Barba.js:** `barba.init({ transitions: [{ name: 'panel-wipe', async leave() { await cover(); }, enter({ next }) { reveal(); gsap.from(next.container.querySelectorAll('.pw__rise'), { y: 40, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.06 }); } }] })` — Barba swaps the container between `leave` and `enter`, exactly where the callback sits in the demo timeline.
**Interaction:** Any element with `data-page` (nav links, brand, the CTA) triggers `transitionTo` on click (`preventDefault`); clicks during a transition are ignored. Hovering a nav link brightens it in 0.25s; the active link grows a `#d4ff3a` underline over 0.35s. No scroll dependency.
**Responsive behavior:** Pure CSS, no `gsap.matchMedia()` needed — the timeline is identical at every size. Under 768px the nav drops the URL label (`grid-template-columns: auto 1fr`), Home stacks into one column with the orb at `min(70vw, 320px)`, Work tiles become a 2-column grid with the third tile spanning both columns at 16:9, and Studio copy left-aligns.
**Accessibility:** `prefers-reduced-motion: reduce` → `transitionTo` calls `show(name)` immediately (no panels, no rise, no autoplay). Links are real anchors with `href`, `aria-current="page"` on the active one and a visible `2px solid var(--accent)` focus ring; the panel layer and artwork are `aria-hidden`. Focus is unaffected by the transition because the nav never leaves the DOM.
**Performance rules:** Only `transform` (`yPercent`, `y`) and `opacity` animate; `will-change: transform` is on the two panels and the rising elements only, and `clearProps` removes inline styles after each rise so pages stay clean. Panels are `position: fixed` with `pointer-events: none` so they never trigger layout or hit-testing. Everything lives in `gsap.context()` and is reverted on unmount; the autoplay timer is cleared.
**Customization knobs:**
- `COVER` (0.8s) / `REVEAL` (0.8s) — sheet travel time; `STAGGER` (0.08s) — gap between the two sheets.
- Ease `'power4.inOut'` — `'expo.inOut'` for a sharper snap.
- Panel colours (`--surface`, accent `#d4ff3a`); add a third panel for a tricolour wipe (the stagger handles any count).
- Rise distance (40px) and stagger (0.06) for the incoming content.
- `AUTOPLAY_DELAY` (1.2s) — remove in production.
**Acceptance criteria:**
- [ ] Clicking a link covers the screen from the bottom with the dark sheet leading the accent one by 0.08s.
- [ ] The page content changes only while fully covered; the URL label and active underline change at the same moment.
- [ ] The accent sheet exits first through the top; the new content rises in under the lifting sheets.
- [ ] A second click mid-transition is ignored; the panels end parked at `yPercent: 100`.
- [ ] Reduced-motion users get an instant page swap with no sheets; keyboard users can tab through the nav and trigger the transition with Enter.
- [ ] No layout shift, no console errors, no visible seam between the two sheets.
Source
Full source code
<div class="pw">
<header class="pw__nav">
<a href="#home" class="pw__brand" data-page="home">Ferrule</a>
<p class="pw__url" aria-hidden="true">ferrule.studio<span class="pw__url-path">/home</span></p>
<nav class="pw__links" aria-label="Demo pages">
<a href="#home" class="pw__link is-active" data-page="home" aria-current="page">Home</a>
<a href="#work" class="pw__link" data-page="work">Work</a>
<a href="#studio" class="pw__link" data-page="studio">Studio</a>
</nav>
</header>
<main class="pw__stage">
<section class="pw__page pw__page--home is-active" data-page="home">
<div class="pw__home-copy">
<p class="pw__rise pw__eyebrow">Ferrule — furniture</p>
<h1 class="pw__rise pw__h">Rooms that <em>breathe</em>.</h1>
<p class="pw__rise pw__p">Oak, linen and brass, built in small runs by people who will still answer the phone in ten years.</p>
<a href="#work" class="pw__rise pw__cta" data-page="work">See the work</a>
</div>
<div class="pw__rise pw__art pw__art--orb" aria-hidden="true"></div>
</section>
<section class="pw__page pw__page--work" data-page="work">
<div class="pw__work-head">
<p class="pw__rise pw__eyebrow">Selected work</p>
<h1 class="pw__rise pw__h">Three houses, one <em>grain</em>.</h1>
</div>
<ul class="pw__tiles" role="list">
<li class="pw__rise pw__tile" style="--c1:#5c3a2a;--c2:#0a0a0a"><span class="pw__tile-name">Linden House</span><span class="pw__tile-meta">Dining · 2026</span></li>
<li class="pw__rise pw__tile" style="--c1:#2a5c48;--c2:#0a0a0a"><span class="pw__tile-name">Ash Hall</span><span class="pw__tile-meta">Library · 2025</span></li>
<li class="pw__rise pw__tile" style="--c1:#2a3d5c;--c2:#0a0a0a"><span class="pw__tile-name">Court Studio</span><span class="pw__tile-meta">Workspace · 2025</span></li>
</ul>
</section>
<section class="pw__page pw__page--studio" data-page="studio">
<div class="pw__studio-copy">
<p class="pw__rise pw__eyebrow">Studio</p>
<h1 class="pw__rise pw__h">A workshop in Lisbon, a desk in <em>Oslo</em>.</h1>
<address class="pw__rise pw__address">Rua da Boavista 84 · Lisboa<br>Tordenskiolds gate 3 · Oslo<br>hello@ferrule.studio</address>
</div>
<div class="pw__rise pw__art pw__art--band" aria-hidden="true"></div>
</section>
</main>
<div class="pw__panels" aria-hidden="true">
<div class="pw__panel pw__panel--surface"></div>
<div class="pw__panel pw__panel--accent"></div>
</div>
</div>
.pw { min-height: 100vh; display: flex; flex-direction: column; }
.pw__nav { position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 64px; padding-inline: clamp(1.25rem, 4vw, 3rem); background: var(--bg); border-bottom: 1px solid var(--line); }
.pw__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.15rem; letter-spacing: -0.02em; text-decoration: none; }
.pw__url { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .04em; }
.pw__url-path { color: var(--text); }
.pw__links { display: flex; justify-content: flex-end; gap: clamp(1rem, 2.5vw, 2rem); }
.pw__link { position: relative; font-size: .85rem; color: var(--muted); text-decoration: none; padding: .4rem 0; transition: color .25s; }
.pw__link::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--knob-accent, var(--accent)); transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.65,0,.35,1); }
.pw__link:hover, .pw__link.is-active { color: var(--text); }
.pw__link.is-active::after { transform: scaleX(1); }
.pw a:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 4px; }
.pw__stage { position: relative; flex: 1; display: grid; }
.pw__page { grid-area: 1 / 1; display: none; min-height: calc(100vh - 64px); padding: clamp(1.5rem, 5vw, 4rem) clamp(1.25rem, 4vw, 3rem); }
.pw__page.is-active { display: grid; }
.pw__rise { will-change: transform, opacity; }
.pw__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 1.25rem; }
.pw__h { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.6rem, 6.4vw, 6.4rem); line-height: .95; letter-spacing: -0.04em; text-wrap: balance; }
.pw__h em { font-style: italic; }
.pw__p { max-width: 40ch; margin-top: 1.5rem; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.5; }
.pw__cta { display: inline-block; margin-top: 2.25rem; padding: .75em 1.5em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; font-size: .85rem; font-weight: 500; text-decoration: none; transition: background .25s, color .25s, border-color .25s; }
.pw__cta:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
/* Home: copy + orb */
.pw__page--home { grid-template-columns: 1.1fr .9fr; align-items: center; gap: clamp(2rem, 5vw, 4rem); }
.pw__art--orb { width: min(100%, 520px); aspect-ratio: 1; margin-inline: auto; border-radius: 50%; background: radial-gradient(circle at 32% 30%, #fff6dc 0%, #e9b36a 16%, #7a3f2c 44%, #1a1412 70%, #0a0a0a 100%); position: relative; }
.pw__art--orb::before { content: ''; position: absolute; inset: -12% -18% -28%; border-radius: 50%; background: radial-gradient(ellipse at 50% 60%, rgba(233,179,106,.28) 0%, rgba(233,179,106,0) 62%); z-index: -1; }
.pw__art--orb::after { content: ''; position: absolute; inset: -6%; border-radius: 50%; border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); clip-path: inset(0 0 55% 0); }
/* Work: headline + tiles */
.pw__page--work { grid-template-rows: auto 1fr; align-content: start; gap: clamp(2rem, 4vw, 3rem); }
.pw__tiles { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(.75rem, 1.5vw, 1.25rem); align-self: end; }
.pw__tile { position: relative; aspect-ratio: 4 / 5; max-height: 52vh; border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; padding: 1.25rem; color: #fff; background: radial-gradient(120% 80% at 30% 20%, var(--c1), transparent 70%), linear-gradient(160deg, color-mix(in srgb, var(--c1) 60%, #fff 0%) 0%, var(--c2) 100%); }
.pw__tile::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, rgba(255,255,255,.03) 0 1px, transparent 1px 42px); }
.pw__tile-name { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.4vw, 2.2rem); letter-spacing: -0.03em; line-height: 1; }
.pw__tile-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; opacity: .7; margin-top: .5rem; }
/* Studio: right-aligned copy + gradient band */
.pw__page--studio { grid-template-rows: 1fr auto; gap: clamp(2rem, 4vw, 3rem); }
.pw__studio-copy { justify-self: end; text-align: right; max-width: 22ch; align-self: center; }
.pw__studio-copy .pw__h { font-size: clamp(2.4rem, 5.6vw, 5.4rem); }
.pw__address { font-style: normal; font-family: var(--font-mono); font-size: 12px; line-height: 1.8; color: var(--muted); margin-top: 2rem; letter-spacing: .02em; }
.pw__art--band { height: clamp(140px, 24vh, 240px); border-radius: 12px; background: linear-gradient(100deg, #0f1a16 0%, #2d5a4a 35%, #d4ff3a 72%, #f0f0e8 100%); position: relative; overflow: hidden; }
.pw__art--band::after { content: ''; position: absolute; inset: 0; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .2 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); background-size: 160px; mix-blend-mode: overlay; opacity: .6; }
/* The curtains */
.pw__panels { position: fixed; inset: 0; z-index: 40; pointer-events: none; overflow: hidden; }
.pw__panel { position: absolute; inset: -2% 0; visibility: hidden; will-change: transform; } /* parked at yPercent: 100 by JS — no CSS transform, or GSAP would bake it in as pixels */
.pw__panel--surface { background: var(--surface); border-top: 1px solid var(--line); }
.pw__panel--accent { background: var(--knob-accent, var(--accent)); }
@media (max-width: 767px) {
.pw__nav { grid-template-columns: auto 1fr; }
.pw__url { display: none; }
.pw__page--home { grid-template-columns: 1fr; align-content: start; }
.pw__art--orb { width: min(70vw, 320px); }
.pw__tiles { grid-template-columns: 1fr 1fr; }
.pw__tiles .pw__tile:last-child { grid-column: span 2; aspect-ratio: 16 / 9; }
.pw__studio-copy { justify-self: start; text-align: left; max-width: none; }
}
@media (prefers-reduced-motion: reduce) {
.pw__rise, .pw__panel { will-change: auto; }
.pw__link::after { transition: none; }
}
// Panel Wipe Page Transition — two sheets wipe up over the page, the content swaps beneath them, they exit through the top.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { cover: 0.8, reveal: 0.8, stagger: 0.08, ease: 'power4.inOut', rise: 40, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const AUTOPLAY_DELAY = 1.2; // demo-only: play one transition after the fonts are in, so the choreography is visible without a click
// Keep GSAP's clock on wall time: a heavy first paint must never make the curtain lag behind the route change.
gsap.ticker.lagSmoothing(0);
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.pw');
const pages = Object.fromEntries(gsap.utils.toArray('.pw__page').map((p) => [p.dataset.page, p]));
const order = Object.keys(pages);
const links = gsap.utils.toArray('.pw__link');
const urlPath = document.querySelector('.pw__url-path');
const panels = gsap.utils.toArray('.pw__panel'); // [surface, accent] — accent is on top
let current = 'home';
let busy = false;
let interacted = false;
let autoTimer;
function show(name) {
Object.values(pages).forEach((p) => p.classList.toggle('is-active', p.dataset.page === name));
links.forEach((a) => {
const active = a.dataset.page === name;
a.classList.toggle('is-active', active);
active ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current');
});
urlPath.textContent = `/${name}`;
history.replaceState(null, '', `#${name}`);
current = name;
}
// honour a deep link on load
const initial = location.hash.slice(1);
if (pages[initial]) show(initial);
let ctx;
let transitionTo = () => {};
function build(e) {
const fromKnobs = e?.type === 'ml:knobs';
clearTimeout(autoTimer);
ctx?.revert(); // kills any running timeline and restores the inline styles it set
gsap.set('.pw__rise', { clearProps: 'transform,opacity' }); // a rebuild mid-rise must never leave content parked invisible
busy = false;
root.style.setProperty('--knob-accent', K('accent'));
const COVER = K('cover');
const REVEAL = K('reveal');
const STAGGER = K('stagger');
const EASE = K('ease');
const RISE = K('rise');
ctx = gsap.context(() => {
gsap.set(panels, { yPercent: 100, visibility: 'visible' }); // park below the viewport (CSS keeps them hidden until this runs)
transitionTo = (name) => {
if (busy || !pages[name] || name === current) return;
busy = true;
if (reduced) { show(name); busy = false; return; }
const incoming = pages[name].querySelectorAll('.pw__rise');
gsap.timeline({ defaults: { ease: EASE }, onComplete: () => { gsap.set(panels, { yPercent: 100 }); busy = false; } })
// 1. cover: surface first, accent STAGGER behind
.fromTo(panels, { yPercent: 100 }, { yPercent: 0, duration: COVER, stagger: STAGGER })
// 2. swap the page while fully covered
.add(() => {
show(name);
window.scrollTo(0, 0);
gsap.set(incoming, { y: RISE, opacity: 0 });
}, '+=0.02')
// 3. reveal: accent leaves first (negative stagger), surface is the last thing to uncover the page
.to(panels, { yPercent: -100, duration: REVEAL, stagger: -STAGGER }, '+=0.05')
// 4. the new content rises in under the lifting panels
.to(incoming, { y: 0, opacity: 1, duration: 1, ease: 'power3.out', stagger: 0.06, clearProps: 'transform,opacity' }, '<+=0.25');
};
const onClick = (e) => {
const a = e.target.closest('[data-page]');
if (!a) return;
e.preventDefault();
interacted = true;
clearTimeout(autoTimer);
transitionTo(a.dataset.page);
};
root.addEventListener('click', onClick);
if (!reduced) {
if (fromKnobs) {
// Playground: show the new values straight away by moving to the next page
autoTimer = setTimeout(() => transitionTo(order[(order.indexOf(current) + 1) % order.length]), 150);
} else {
// Demo autoplay: once the fonts are in, play one Home → Work so the choreography is visible without a click.
document.fonts.ready.then(() => {
autoTimer = setTimeout(() => { if (!interacted && current === 'home') transitionTo('work'); }, AUTOPLAY_DELAY * 1000);
});
}
}
return () => { root.removeEventListener('click', onClick); clearTimeout(autoTimer); };
});
}
build();
// Playground: rebuild with the new values (and play one transition so they are visible)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());