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.
- Text
- gsap
- splittext
- beginner
- light perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **SplitText Character Reveal**. Target my existing headline element (keep its tag, classes and copy). Load GSAP 3.13+ and SplitText, wait for `document.fonts.ready`, then split it with `type: "lines,chars", mask: "lines", autoSplit: true` and animate the chars with `from { yPercent: 110 }`, `duration: 1`, `ease: "power4.out"`, `stagger: 0.02`, starting 0.2s after load. Keep the headline `visibility: hidden` until the split is ready, then set `autoAlpha: 1`. If a paragraph follows the headline, fade it in (`opacity 0→1, y 24→0, 1s, power3.out`) overlapping the last 0.3s. Respect `prefers-reduced-motion` by skipping the split and showing the text immediately. Wrap in `gsap.context()` and revert on unmount. Do not change my fonts, colours or layout. ### EFFECT (React / Next.js): SplitText Character Reveal
Build **SplitText Character Reveal** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/SplitTextCharacterReveal.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, SplitText);
export default function SplitTextCharacterReveal() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="char-reveal">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`SplitTextCharacterReveal.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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 from nowhere.</p>
<button class="char-reveal__replay" type="button">Replay</button>
</div>
</section>
```
```css
.char-reveal { min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg); color: var(--text); }
.char-reveal__inner { max-width: 1100px; width: 100%; }
.char-reveal__eyebrow::before { content: ''; display: inline-block; width: .5em; height: .5em; border-radius: 50%; background: var(--knob-accent, var(--accent)); margin-right: .6em; vertical-align: middle; }
.char-reveal__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); opacity: 0; }
.char-reveal__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30;
font-size: clamp(2.75rem, 8vw, 8rem); line-height: .95; letter-spacing: -0.04em; margin: .5em 0 .4em;
visibility: hidden; text-wrap: balance;
}
.char-reveal__title .line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.char-reveal__title .char { display: inline-block; will-change: transform; }
.char-reveal__copy { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.3vw, 1.25rem); line-height: 1.5; opacity: 0; }
.char-reveal__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; color: var(--text); opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.char-reveal__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.char-reveal__replay:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.char-reveal__title { visibility: visible; }
.char-reveal__eyebrow, .char-reveal__copy { opacity: 1; }
.char-reveal__replay { display: none; }
}
```
```js
// SplitText Character Reveal — masked chars rise through the baseline.
gsap.registerPlugin(SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { stagger: 0.02, duration: 1, travel: 110, ease: 'power4.out', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.char-reveal');
const title = document.querySelector('.char-reveal__title');
const copy = document.querySelector('.char-reveal__copy');
const eyebrow = document.querySelector('.char-reveal__eyebrow');
const replay = document.querySelector('.char-reveal__replay');
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
ctx = gsap.context(() => {
if (reduced) { gsap.set([title, copy, eyebrow], { autoAlpha: 1, y: 0 }); return; }
const mm = gsap.matchMedia();
let tl;
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
const stagger = mobile ? K('stagger') * 0.6 : K('stagger');
gsap.set(copy, { y: mobile ? 16 : 24 });
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto',
onSplit(self) {
tl = gsap.timeline({ defaults: { ease: K('ease') } });
tl.set(title, { autoAlpha: 1 })
.from(eyebrow, { y: 16, opacity: 0, duration: 0.8 }, 0.1)
.from(self.chars, { yPercent: K('travel'), duration: K('duration'), stagger }, 0.2)
.to(copy, { opacity: 1, y: 0, duration: 1, ease: 'power3.out' }, '-=0.3')
.to(replay, { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl; // lets SplitText revert + rebuild cleanly on resize
},
});
});
return () => tl?.kill();
});
replay.addEventListener('click', () => tl?.restart());
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): SplitText Character Reveal
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **SplitText Character Reveal** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SplitText Character Reveal</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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 from nowhere.</p>
<button class="char-reveal__replay" type="button">Replay</button>
</div>
</section>
```
```css
.char-reveal { min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg); color: var(--text); }
.char-reveal__inner { max-width: 1100px; width: 100%; }
.char-reveal__eyebrow::before { content: ''; display: inline-block; width: .5em; height: .5em; border-radius: 50%; background: var(--knob-accent, var(--accent)); margin-right: .6em; vertical-align: middle; }
.char-reveal__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); opacity: 0; }
.char-reveal__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30;
font-size: clamp(2.75rem, 8vw, 8rem); line-height: .95; letter-spacing: -0.04em; margin: .5em 0 .4em;
visibility: hidden; text-wrap: balance;
}
.char-reveal__title .line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.char-reveal__title .char { display: inline-block; will-change: transform; }
.char-reveal__copy { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.3vw, 1.25rem); line-height: 1.5; opacity: 0; }
.char-reveal__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; color: var(--text); opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.char-reveal__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.char-reveal__replay:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.char-reveal__title { visibility: visible; }
.char-reveal__eyebrow, .char-reveal__copy { opacity: 1; }
.char-reveal__replay { display: none; }
}
```
```js
// SplitText Character Reveal — masked chars rise through the baseline.
gsap.registerPlugin(SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { stagger: 0.02, duration: 1, travel: 110, ease: 'power4.out', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.char-reveal');
const title = document.querySelector('.char-reveal__title');
const copy = document.querySelector('.char-reveal__copy');
const eyebrow = document.querySelector('.char-reveal__eyebrow');
const replay = document.querySelector('.char-reveal__replay');
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
ctx = gsap.context(() => {
if (reduced) { gsap.set([title, copy, eyebrow], { autoAlpha: 1, y: 0 }); return; }
const mm = gsap.matchMedia();
let tl;
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
const stagger = mobile ? K('stagger') * 0.6 : K('stagger');
gsap.set(copy, { y: mobile ? 16 : 24 });
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto',
onSplit(self) {
tl = gsap.timeline({ defaults: { ease: K('ease') } });
tl.set(title, { autoAlpha: 1 })
.from(eyebrow, { y: 16, opacity: 0, duration: 0.8 }, 0.1)
.from(self.chars, { yPercent: K('travel'), duration: K('duration'), stagger }, 0.2)
.to(copy, { opacity: 1, y: 0, duration: 1, ease: 'power3.out' }, '-=0.3')
.to(replay, { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl; // lets SplitText revert + rebuild cleanly on resize
},
});
});
return () => tl?.kill();
});
replay.addEventListener('click', () => tl?.restart());
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): SplitText Character Reveal
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withSplitTextCharacterReveal(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `char-reveal`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.char-reveal { min-height: 100vh; display: grid; place-items: center; padding: 8vw 6vw; background: var(--bg); color: var(--text); }
.char-reveal__inner { max-width: 1100px; width: 100%; }
.char-reveal__eyebrow::before { content: ''; display: inline-block; width: .5em; height: .5em; border-radius: 50%; background: var(--knob-accent, var(--accent)); margin-right: .6em; vertical-align: middle; }
.char-reveal__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); opacity: 0; }
.char-reveal__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30;
font-size: clamp(2.75rem, 8vw, 8rem); line-height: .95; letter-spacing: -0.04em; margin: .5em 0 .4em;
visibility: hidden; text-wrap: balance;
}
.char-reveal__title .line { overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.char-reveal__title .char { display: inline-block; will-change: transform; }
.char-reveal__copy { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.3vw, 1.25rem); line-height: 1.5; opacity: 0; }
.char-reveal__replay {
margin-top: 2.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; color: var(--text); opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.char-reveal__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.char-reveal__replay:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.char-reveal__title { visibility: visible; }
.char-reveal__eyebrow, .char-reveal__copy { opacity: 1; }
.char-reveal__replay { display: none; }
}
// SplitText Character Reveal — masked chars rise through the baseline.
gsap.registerPlugin(SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { stagger: 0.02, duration: 1, travel: 110, ease: 'power4.out', accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.char-reveal');
const title = document.querySelector('.char-reveal__title');
const copy = document.querySelector('.char-reveal__copy');
const eyebrow = document.querySelector('.char-reveal__eyebrow');
const replay = document.querySelector('.char-reveal__replay');
let ctx;
function build() {
ctx?.revert();
section.style.setProperty('--knob-accent', K('accent'));
ctx = gsap.context(() => {
if (reduced) { gsap.set([title, copy, eyebrow], { autoAlpha: 1, y: 0 }); return; }
const mm = gsap.matchMedia();
let tl;
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (mmCtx) => {
const { mobile } = mmCtx.conditions;
const stagger = mobile ? K('stagger') * 0.6 : K('stagger');
gsap.set(copy, { y: mobile ? 16 : 24 });
document.fonts.ready.then(() => {
SplitText.create(title, {
type: 'lines,chars', mask: 'lines', linesClass: 'line', charsClass: 'char', autoSplit: true, aria: 'auto',
onSplit(self) {
tl = gsap.timeline({ defaults: { ease: K('ease') } });
tl.set(title, { autoAlpha: 1 })
.from(eyebrow, { y: 16, opacity: 0, duration: 0.8 }, 0.1)
.from(self.chars, { yPercent: K('travel'), duration: K('duration'), stagger }, 0.2)
.to(copy, { opacity: 1, y: 0, duration: 1, ease: 'power3.out' }, '-=0.3')
.to(replay, { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl; // lets SplitText revert + rebuild cleanly on resize
},
});
});
return () => tl?.kill();
});
replay.addEventListener('click', () => tl?.restart());
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());