Curtain Page Transition
Two coloured panels sweep across to cover the page, the content swaps behind them, and they sweep away to reveal the new page.
Home
Create a curtain page transition with a GSAP timeline. Layer two full-size panels over the page, an accent one and a darker one, both scaled to scaleX(0) with transform-origin left. On navigation, build a timeline: animate both to scaleX(1) over 0.45s with ease "power3.inOut", staggered 0.08s so the dark panel follows the accent one. At the midpoint, while the page is fully covered, swap the content (call the router or set state). Then switch transform-origin to right and animate both back to scaleX(0) with the same ease, the accent panel last. Ignore clicks while the timeline is active. Under prefers-reduced-motion, replace the curtain with a 150ms crossfade. Revert the GSAP context on unmount. The pages are "Home" and "Work".
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import s from './demo.module.css'
export default function CurtainTransition({ labels }: { labels: Record<string, string> }) {
const root = useRef<HTMLDivElement>(null)
const ctx = useRef<gsap.Context | null>(null)
const busy = useRef(false)
const [page, setPage] = useState<'a' | 'b'>('a')
const next = page === 'a' ? 'b' : 'a'
useEffect(() => {
ctx.current = gsap.context(() => {}, root)
return () => ctx.current?.revert()
}, [])
const go = () => {
if (busy.current) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return setPage(next)
busy.current = true
ctx.current?.add(() => {
const panels = [`.${s.accent}`, `.${s.dark}`]
gsap
.timeline({ onComplete: () => void (busy.current = false) })
.set(panels, { transformOrigin: 'left center' })
.to(panels, { scaleX: 1, duration: 0.45, ease: 'power3.inOut', stagger: 0.08 })
.call(() => setPage(next))
.set(panels, { transformOrigin: 'right center' })
.to([...panels].reverse(), { scaleX: 0, duration: 0.45, ease: 'power3.inOut', stagger: 0.08 }, '+=0.05')
})
}
return (
<div ref={root} className={s.view}>
<div className={s.page} data-page={page}>
<span className={s.kicker}>{page === 'a' ? '01' : '02'}</span>
<h3 className={s.heading}>{labels[page]}</h3>
<button type="button" className={s.go} onClick={go}>
{labels.go.replace('{page}', labels[next])} →
</button>
</div>
<span className={`${s.panel} ${s.accent}`} aria-hidden="true" />
<span className={`${s.panel} ${s.dark}`} aria-hidden="true" />
</div>
)
}
.view {
position: absolute;
inset: 0;
overflow: hidden;
}
.page {
display: grid;
align-content: center;
justify-items: start;
gap: 8px;
height: 100%;
padding: 0 32px;
}
.page[data-page='b'] { background: var(--stage-surface); }
.kicker { font: 500 var(--text-sm) / 1 var(--font-mono); color: var(--stage-accent); }
.heading {
margin: 0 0 8px;
font: 700 2.5rem / 1 var(--font-display);
color: var(--stage-ink);
}
.go {
padding: 0;
border: 0;
background: none;
color: var(--stage-ink);
font: 500 var(--text-sm) / 1.4 var(--font-body);
text-decoration: underline;
text-underline-offset: 4px;
cursor: pointer;
}
.go:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.panel {
position: absolute;
inset: 0;
transform: scaleX(0);
transform-origin: left center;
pointer-events: none;
}
.accent { background: var(--stage-accent); }
.dark { background: var(--stage-ink); }