AnimationsIntermediateApril 27, 2026
Text Reveal 01
GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.
View Full Demo →Preview
We create motion-forward digital experiences
No-5 Studio is a motion-forward design studio. We build websites that communicate confidence, craft, and intention through every interaction.
Source
demo.jsx
import TextReveal01 from "./index.jsx";
import styles from "./demo.module.css";
const ITEMS_LOAD = [
{
as: "h1",
text: "We create motion-forward digital experiences",
splitType: "lines",
className: styles.heading,
},
{
as: "p",
text: "No-5 Studio is a motion-forward design studio. We build websites that communicate confidence, craft, and intention through every interaction.",
splitType: "words",
delay: 0.3,
},
];
const ITEMS_SCROLL = [
{
as: "h2",
text: "Scroll-triggered line reveal",
splitType: "lines",
scroll: true,
className: styles.heading,
},
{
as: "p",
text: "This paragraph reveals word by word as it enters the viewport. The animation plays once by default.",
splitType: "words",
scroll: true,
},
];
const ITEMS_CHARS = [
{
as: "h2",
text: "Character reveal on scroll",
splitType: "chars",
scroll: true,
className: styles.heading,
},
{
as: "p",
text: "Each letter animates individually with a tight stagger for a typewriter-like entrance.",
splitType: "chars",
scroll: true,
},
];
const ITEMS_SCRUB = [
{
as: "h2",
text: "Scrubbed scroll reveal",
splitType: "words",
scroll: "scrub",
className: styles.heading,
},
{
as: "p",
text: "This text follows the scroll position — scrub forward to reveal, scrub back to hide.",
splitType: "words",
scroll: "scrub",
},
];
export default function TextReveal01Demo() {
return (
<div>
{/* Hero — load-time reveal */}
<section className={styles.section} data-bg="dark">
<p className={styles.label}>Load reveal</p>
<TextReveal01 items={ITEMS_LOAD} />
</section>
{/* Scroll-triggered reveal */}
<section className={styles.section}>
<p className={styles.label}>Scroll trigger</p>
<TextReveal01 items={ITEMS_SCROLL} />
</section>
{/* Character reveal */}
<section className={styles.section} data-bg="dark">
<p className={styles.label}>Characters</p>
<TextReveal01 items={ITEMS_CHARS} />
</section>
{/* Scrubbed reveal */}
<section className={styles.section}>
<p className={styles.label}>Scrub</p>
<TextReveal01 items={ITEMS_SCRUB} />
</section>
<div className={styles.spacer} />
</div>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";
gsap.registerPlugin(SplitText, ScrollTrigger);
/* ── GSAP text reveal engine ───────────────────────────────── */
function textReveal01(scope = document, delay = 0, { ignoreManual = false } = {}) {
const CONFIG = {
lines: { duration: 1, stagger: 0.06, ease: "expo.out" },
words: { duration: 1, stagger: 0.03, ease: "expo.out" },
chars: { duration: 0.6, stagger: 0.01, ease: "expo.out" },
scrollStart: "top 72%",
scrubStart: "top 80%",
scrubEnd: "top 20%",
once: true,
markers: false,
};
const allSplitEls = scope.querySelectorAll("[data-reveal-01]");
const autoEls = ignoreManual
? [...allSplitEls]
: [...allSplitEls].filter((el) => !el.hasAttribute("data-manual"));
gsap.set(autoEls, { visibility: "visible" });
allSplitEls.forEach((el) => {
const splitType = el.getAttribute("data-reveal-01");
const c = CONFIG[splitType];
if (!c) return;
let type;
let mask;
let linesClass;
let wordsClass;
let charsClass;
switch (splitType) {
case "lines":
type = "lines";
mask = "lines";
linesClass = "line";
break;
case "words":
type = "words, lines";
mask = "words";
wordsClass = "word";
linesClass = "line";
break;
case "chars":
type = "chars, words, lines";
mask = "chars";
charsClass = "char";
wordsClass = "word";
linesClass = "line";
break;
default:
return;
}
if (!ignoreManual && el.hasAttribute("data-manual")) {
SplitText.create(el, {
type,
mask,
autoSplit: true,
...(linesClass && { linesClass }),
...(wordsClass && { wordsClass }),
...(charsClass && { charsClass }),
});
return;
}
const scrollMode = el.getAttribute("data-scroll");
const useScroll = el.hasAttribute("data-scroll");
const useScrub = scrollMode === "scrub";
SplitText.create(el, {
type,
mask,
autoSplit: true,
...(linesClass && { linesClass }),
...(wordsClass && { wordsClass }),
...(charsClass && { charsClass }),
onSplit(instance) {
const durationValue = parseFloat(el.dataset.duration);
const staggerValue = parseFloat(el.dataset.stagger);
const delayValue = parseFloat(el.dataset.delay);
const duration = Number.isNaN(durationValue) ? c.duration : durationValue;
const stagger = Number.isNaN(staggerValue) ? c.stagger : staggerValue;
const elDelay = Number.isNaN(delayValue) ? 0 : delayValue;
const ease = el.dataset.ease || c.ease;
const targets = instance[splitType];
const once = el.hasAttribute("data-once")
? el.getAttribute("data-once") !== "false"
: CONFIG.once;
const tween = {
yPercent: 110,
duration,
stagger,
delay: useScroll ? elDelay : elDelay + delay,
immediateRender: true,
ease,
};
if (useScrub) {
tween.scrollTrigger = {
trigger: el,
start: CONFIG.scrubStart,
end: CONFIG.scrubEnd,
scrub: true,
markers: CONFIG.markers,
...(once && { onLeave: (self) => self.kill(false) }),
};
} else if (useScroll) {
const start = scrollMode || CONFIG.scrollStart;
tween.scrollTrigger = {
trigger: el,
start: `clamp(${start})`,
markers: CONFIG.markers,
...(once ? { once: true } : { toggleActions: "play none none reverse" }),
};
}
return gsap.from(targets, tween);
},
});
});
}
/* ── React component ────────────────────────────────────────── */
export default function TextReveal01({ items = [] }) {
const containerRef = useRef(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ctx = gsap.context(() => {
textReveal01(el);
}, el);
return () => ctx.revert();
}, []);
return (
<div ref={containerRef} className={styles.container}>
{items.map((item, i) => {
const Tag = item.as || "p";
const dataAttrs = {};
dataAttrs["data-reveal-01"] = item.splitType || "lines";
if (item.scroll != null) {
dataAttrs["data-scroll"] = item.scroll === true ? "" : item.scroll;
}
if (item.duration != null) dataAttrs["data-duration"] = String(item.duration);
if (item.stagger != null) dataAttrs["data-stagger"] = String(item.stagger);
if (item.delay != null) dataAttrs["data-delay"] = String(item.delay);
if (item.ease != null) dataAttrs["data-ease"] = item.ease;
if (item.once != null) dataAttrs["data-once"] = String(item.once);
if (item.manual) dataAttrs["data-manual"] = "";
return (
<Tag
key={i}
className={`${styles.text} ${item.className || ""}`}
{...dataAttrs}
>
{item.text}
</Tag>
);
})}
</div>
);
}
demo.module.css
.section {
min-height: 100vh;
background: #ffffff;
color: #1a1a1a;
padding: 6rem 3rem;
display: flex;
flex-direction: column;
justify-content: center;
}
.section[data-bg="dark"] {
background: #0d0d0d;
color: #ffffff;
}
.label {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.35;
margin-bottom: 2rem;
}
.heading {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
}
.spacer {
height: 30vh;
background: #ffffff;
}
styles.module.css
.container {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 4rem 2rem;
max-width: 720px;
margin: 0 auto;
}
.text {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--color-foreground, #1a1a1a);
}
/* ── Required hidden-state + SplitText mask rules ──────────── */
:global([data-reveal-01]) {
visibility: hidden;
}
:global([data-reveal-01] > *) {
margin-bottom: -0.1em;
}
:global([data-reveal-01] .word-mask),
:global([data-reveal-01] .char-mask) {
vertical-align: top;
}
:global([data-reveal-01] .line-mask > *),
:global([data-reveal-01] .word-mask > *),
:global([data-reveal-01] .char-mask > *) {
padding-bottom: 0.1em;
will-change: transform;
}
Dependencies
gsap