遮罩逐行升起
标题的每一行依次从一条看不见的边缘后面升起,像排好的字一行行落位。
每一帧都是一次取舍
用 GSAP SplitText 制作带遮罩的逐行标题显现。注册 SplitText,在 document.fonts.ready 之后用 SplitText.create(el, { type: "lines", mask: "lines", autoSplit: true, onSplit: self => gsap.from(self.lines, { yPercent: 110, duration: 0.9, ease: "power4.out", stagger: 0.12 }) }) 拆分标题。mask 选项会把每一行包进一个裁切溢出的父元素,让每行从看不见的边缘后面升起;autoSplit 会在尺寸变化时重新拆分,保证换行正确。当 IntersectionObserver 检测到标题进入视口时触发,并添加一个“重播”按钮重新开始补间。SplitText 会给元素加上 aria-label,让屏幕阅读器只读一遍。在 prefers-reduced-motion 下不拆分,直接显示文字。组件卸载时 revert GSAP context,拆分也随之还原。标题为“每一帧都是一次取舍”。
源代码
'use client'
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
import { SplitText } from 'gsap/SplitText'
import s from './demo.module.css'
gsap.registerPlugin(SplitText)
export default function SplitRise({ labels }: { labels: Record<string, string> }) {
const heading = useRef<HTMLHeadingElement>(null)
const tween = useRef<gsap.core.Tween | null>(null)
useEffect(() => {
const el = heading.current
if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
let ctx: gsap.Context | undefined
let io: IntersectionObserver | undefined
let cancelled = false
document.fonts.ready.then(() => {
if (cancelled) return
ctx = gsap.context(() => {
SplitText.create(el, {
type: 'lines',
mask: 'lines',
autoSplit: true,
onSplit: (self) => {
tween.current = gsap.from(self.lines, { yPercent: 110, duration: 0.9, ease: 'power4.out', stagger: 0.12, paused: true })
return tween.current
},
})
})
io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return
tween.current?.play()
io?.disconnect()
},
{ threshold: 0.5 },
)
io.observe(el)
})
return () => {
cancelled = true
io?.disconnect()
ctx?.revert()
}
}, [labels.text])
return (
<div className={s.wrap}>
<h3 ref={heading} className={s.heading}>
{labels.text}
</h3>
<button type="button" className={s.replay} onClick={() => tween.current?.restart()}>
{labels.replay}
</button>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 16px;
max-width: 280px;
}
.heading {
margin: 0;
font: 700 var(--text-2xl) / 1.15 var(--font-display);
letter-spacing: -0.02em;
text-align: center;
color: var(--stage-ink);
}
.replay {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.replay:hover { border-color: var(--stage-accent); }
.replay:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }