遮罩逐行升起

标题的每一行依次从一条看不见的边缘后面升起,像排好的字一行行落位。

  • GSAP
  • 文字
  • 进场
  • 收录于

每一帧都是一次取舍

AI 提示词

用 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,拆分也随之还原。标题为“每一帧都是一次取舍”。

源代码

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

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。