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.

  • GSAP
  • Layout
  • Click
  • Added
01

Home

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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); }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.