Masked Line Rise

Each line of a headline slides up from behind an invisible edge, one after another, like type set into place.

  • GSAP
  • Text
  • Entrance
  • Added

Every frame is a decision

AI prompt

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

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; }

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.