Masked Line Rise
Each line of a headline slides up from behind an invisible edge, one after another, like type set into place.
Every frame is a decision
Create a masked line-by-line headline reveal with GSAP SplitText. Register SplitText and, after document.fonts.ready resolves, split the heading with 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 }) }). The mask option wraps each line in an overflow-clipped parent, so lines rise from behind an invisible edge, and autoSplit re-splits on resize so line breaks stay correct. Trigger it when an IntersectionObserver sees the heading, and add a Replay button that restarts the tween. SplitText adds aria-label to the element so screen readers read it once. Under prefers-reduced-motion, show the text without splitting. Revert the GSAP context, and with it the split, on unmount. The heading reads "Every frame is a decision".
Source
'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; }