幕布转场
两块彩色面板扫过来盖住页面,内容在后面切换,然后面板扫开露出新页面。
01
首页
用 GSAP 时间线制作幕布式页面转场。在页面上叠两块全尺寸面板,一块主色、一块更深的颜色,初始都为 scaleX(0),transform-origin 为 left。导航时建立时间线:两块面板在 0.45s 内动到 scaleX(1),ease "power3.inOut",错开 0.08s,让深色面板跟在主色面板后面。在中点、页面被完全遮住时切换内容(调用路由或修改 state)。然后把 transform-origin 改为 right,用同样的缓动把两块面板动回 scaleX(0),主色面板最后离开。时间线进行中忽略点击。在 prefers-reduced-motion 下用 150ms 的交叉淡化代替幕布。组件卸载时 revert GSAP context。页面为“首页”和“作品”。
源代码
'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); }