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.

Loading

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.

prompt.md · Panel Wipe Page Transition
### 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>