SplitText Character Reveal

A headline is split into characters that rise out of a masked line one after another, followed by a soft fade-in of the supporting copy and a replay button.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 859 words of exact values — rewritten live as you tune the Playground.

prompt.md · SplitText Character Reveal
### EFFECT: SplitText Character Reveal
**What it does (visual):** The page loads on a dark background with an empty space where a large serif headline will be. After 0.2s, each character of the headline rises from below an invisible baseline (it is clipped by a mask around each line), the characters appearing left-to-right about 0.02s apart, each one travelling 110% of its height upward over 1s while decelerating smoothly with the `power4.out` curve. A two-line headline finishes in roughly 1.1s. 0.3s before the headline finishes, a short muted paragraph below it fades in and drifts up 24px. A small outlined "Replay" button fades in last; clicking it resets the headline and plays the sequence again. Nothing is visible before the animation starts (no flash of unstyled text).

**Tech stack:**
- GSAP 3.13+ with SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` and `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(SplitText);`
- Fonts: any variable serif for the headline (demo uses Fraunces 300), a sans for body copy.

**HTML structure:**
```html
<section class="char-reveal">
  <div class="char-reveal__inner">
    <p class="char-reveal__eyebrow">Text · SplitText</p>
    <h1 class="char-reveal__title">Letters that rise through the baseline</h1>
    <p class="char-reveal__copy">Each character is masked by its line, so the words appear to surface from the page instead of fading in.</p>
    <button class="char-reveal__replay" type="button">Replay</button>
  </div>
</section>
```

**CSS requirements:**
- `.char-reveal`: `min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.char-reveal__inner`: `max-width: 1100px; width: 100%;`
- `.char-reveal__title`: `font-family: var(--font-display, Georgia, serif); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); line-height: 0.95; letter-spacing: -0.04em; margin: 0.5em 0 0.4em; visibility: hidden;` (visibility hidden until JS splits it — JS sets `autoAlpha: 1` on the title right before animating so no FOUC).
- `.char-reveal__title .line`: `overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em;` (SplitText `mask: "lines"` adds the wrapper; the padding stops descenders from being clipped).
- `.char-reveal__title .char`: `display: inline-block; will-change: transform;`
- `.char-reveal__copy`: `max-width: 46ch; color: var(--muted, #8a8a8a); font-size: clamp(1rem, 1.3vw, 1.25rem); line-height: 1.5; opacity: 0;`
- `.char-reveal__eyebrow`: mono font, 11px, uppercase, letter-spacing 0.12em, muted color, opacity 0.
- `.char-reveal__replay`: outlined pill button, `border: 1px solid rgba(255,255,255,.2); border-radius: 999px; padding: .7em 1.4em; margin-top: 2.5rem; opacity: 0;` hover inverts to white background/dark text.
- Accent colour (focus ring, eyebrow dot): `--accent: #d4ff3a`.
- Light theme: honour `[data-theme="light"]` by swapping `--bg/--text/--muted`.

**Animation spec (exact values):**
1. Wait for `document.fonts.ready`, then `SplitText.create(".char-reveal__title", { type: "lines,chars", mask: "lines", linesClass: "line", charsClass: "char", autoSplit: true, onSplit: (self) => build(self) })`.
2. Inside `build(split)` create `tl = gsap.timeline({ defaults: { ease: "power4.out" } })`:
   - `tl.set(".char-reveal__title", { autoAlpha: 1 })`
   - `tl.from(split.chars, { yPercent: 110, duration: 1, stagger: 0.02, ease: "power4.out" }, 0.2)` — target: every character; from `yPercent: 110` → `0`; duration 1s; ease `power4.out`; stagger 0.02s; delay via position 0.2.
   - `tl.from(".char-reveal__eyebrow", { y: 16, opacity: 0, duration: 0.8 }, 0.1)`
   - `tl.to(".char-reveal__copy", { opacity: 1, y: 0, duration: 1, ease: "power3.out" }, "-=0.3")` — set initial `y: 24` with `gsap.set` before the timeline.
   - `tl.to(".char-reveal__replay", { opacity: 1, duration: 0.6, ease: "power2.out" }, "-=0.6")`
3. Return `tl` from `onSplit` so SplitText can revert/replay it cleanly on resize.
4. Replay button: `tl.restart()` (a 0.2s offset is baked into the timeline so the restart has a tiny beat).

**Interaction:** Hovering the Replay button inverts its colours over 0.25s. Clicking restarts the timeline. No scroll dependency — the effect plays on load.

**Responsive behavior:** Font size is fluid via `clamp()`. Under 768px, use `gsap.matchMedia()` to reduce the per-character stagger to 0.012s (more characters per line wrap → keeps total duration ≈1s) and the copy's `y` offset to 16px. On touch devices the hover inversion is skipped (button has a visible border instead).

**Accessibility:** If `prefers-reduced-motion: reduce` matches, do not split or animate: set the title, eyebrow, copy and button to full opacity immediately (`gsap.set([...], { autoAlpha: 1, y: 0 })`) and hide the Replay button. SplitText keeps the original text accessible (`aria-label` is applied to the heading automatically via `aria: "auto"`). Button has a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).

**Performance rules:** Only `transform` and `opacity` are animated. `will-change: transform` is on `.char` only during the animation (SplitText reverts remove it). Everything lives inside `gsap.context()` and is reverted on unmount (`ctx.revert()`), which also reverts the split so the DOM returns to a single text node.

**Customization knobs:**
- `STAGGER` (0.02) — rhythm between characters.
- `DURATION` (1) — per-character travel time.
- `yPercent` (110) — travel distance; use 100–130.
- `ease` — `power4.out` (current), `expo.out` for snappier, `back.out(1.2)` for a slight overshoot.
- `--accent` (#d4ff3a).
- `type: "lines,words"` instead of chars for a calmer word-by-word version.

**Acceptance criteria:**
- [ ] No text is visible before the animation begins; no layout shift when the split happens.
- [ ] Characters are clipped by their line (you never see them below the baseline before they rise).
- [ ] Headline finishes in ~1–1.2s; copy begins fading 0.3s before the last character lands.
- [ ] Replay restarts the full sequence; resizing the window re-splits without duplicating characters.
- [ ] With reduced motion enabled, all text is simply visible and the Replay button is hidden.

Source

Full source code

<section class="char-reveal">
  <div class="char-reveal__inner">
    <p class="char-reveal__eyebrow">Text · SplitText</p>
    <h1 class="char-reveal__title">Letters that rise through the baseline</h1>
    <p class="char-reveal__copy">Each character is masked by its line, so the words appear to surface from the page instead of fading in from nowhere.</p>
    <button class="char-reveal__replay" type="button">Replay</button>
  </div>
</section>