Staggered Word Reveal
Use When
- A short headline, intro, or pull quote should reveal word by word.
- The motion should feel editorial, premium, and restrained.
- The reveal should trigger only once when the text enters the viewport.
- The project does not need heavy GSAP SplitText behavior.
Motion Defaults
- Initial state:
opacity: 0, transform: translateY(20px).
- Final state:
opacity: 1, transform: translateY(0).
- Duration:
0.8s.
- Ease:
cubic-bezier(0.16, 1, 0.3, 1).
- Stagger:
0.06s to 0.08s per word. Default to 0.07s.
- Trigger: start around
20% visible, with a slight lower viewport bias.
- Replay: once only.
HTML
<h1 class="word-reveal" data-word-reveal>
Build interfaces that feel calm, cinematic, and alive.
</h1>
CSS
Keep no-JS content visible. Hide only after JavaScript is active and before the text has been split.
.word-reveal {
visibility: visible;
}
html.js .word-reveal[data-word-reveal]:not(.is-ready) {
opacity: 0;
}
.word-reveal__word {
display: inline-block;
opacity: 0;
transform: translate3d(0, 20px, 0);
transition:
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: calc(var(--word-index) * 0.07s);
will-change: opacity, transform;
}
.word-reveal.is-visible .word-reveal__word {
opacity: 1;
transform: translate3d(0, 0, 0);
}
@media (prefers-reduced-motion: reduce) {
html.js .word-reveal[data-word-reveal]:not(.is-ready),
.word-reveal__word {
opacity: 1;
transform: none;
transition: none;
}
}
JavaScript
This splitter preserves spaces, avoids innerHTML, exposes the original sentence to screen readers, and unobserves after the first reveal.
document.documentElement.classList.add("js");
function splitWordReveal(element) {
if (element.dataset.wordRevealReady === "true") return;
const text = element.textContent || "";
const parts = text.split(/(\s+)/);
let wordIndex = 0;
element.textContent = "";
element.setAttribute("aria-label", text.trim());
parts.forEach((part) => {
if (!part.trim()) {
element.appendChild(document.createTextNode(part));
return;
}
const word = document.createElement("span");
word.className = "word-reveal__word";
word.setAttribute("aria-hidden", "true");
word.style.setProperty("--word-index", wordIndex);
word.textContent = part;
element.appendChild(word);
wordIndex += 1;
});
element.dataset.wordRevealReady = "true";
element.classList.add("is-ready");
}
function initWordReveals(selector = "[data-word-reveal]") {
const elements = Array.from(document.querySelectorAll(selector));
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion || !("IntersectionObserver" in window)) {
elements.forEach((element) => {
element.classList.add("is-ready", "is-visible");
});
return;
}
const observer = new IntersectionObserver(
(entries, io) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
});
},
{
threshold: 0.2,
rootMargin: "0px 0px -10% 0px",
}
);
elements.forEach((element) => {
splitWordReveal(element);
observer.observe(element);
});
}
document.addEventListener("DOMContentLoaded", () => {
initWordReveals();
});
Framework Notes
- React/Vue/Svelte: run the splitter after mount, then clean up observer instances on route changes.
- Framer Motion: keep the same tokens:
y: 20, opacity: 0, duration 0.8, ease [0.16, 1, 0.3, 1], stagger 0.06 to 0.08, once: true.
- GSAP: use
fromTo(words, { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, ease: "expo.out", stagger: 0.07 }).
Taste Rules
- Use on short text: headlines, subheads, labels, and quotes. Avoid long paragraphs.
- Stagger words, not letters, for a calmer premium feel.
- Keep the offset subtle. Do not add bounce, rotation, or large blur.
- Animate
transform and opacity only.
- Do not split text containing links, buttons, or meaningful inline markup.
- If wrapping is important, initialize after web fonts are ready.
Quick Checks
- Text is visible when JavaScript is disabled.
- Words begin at
translateY(20px) and opacity: 0.
- Each word reveals once with a
0.06s to 0.08s delay.
- Repeated scrolling does not replay the animation.
- Reduced-motion users see static readable text.
1---2name: mengto-staggered-word-reveal3description: Use when creating subtle editorial word-by-word text reveals where each word fades and rises into place on viewport entry—premium portfolio headlines, hero copy, section intros, and short marketing text needing a cinematic staggered reveal with IntersectionObserver or in-view detection.4---56# Staggered Word Reveal78## Use When9- A short headline, intro, or pull quote should reveal word by word.10- The motion should feel editorial, premium, and restrained.11- The reveal should trigger only once when the text enters the viewport.12- The project does not need heavy GSAP SplitText behavior.1314## Motion Defaults15- Initial state: `opacity: 0`, `transform: translateY(20px)`.16- Final state: `opacity: 1`, `transform: translateY(0)`.17- Duration: `0.8s`.18- Ease: `cubic-bezier(0.16, 1, 0.3, 1)`.19- Stagger: `0.06s` to `0.08s` per word. Default to `0.07s`.20- Trigger: start around `20%` visible, with a slight lower viewport bias.21- Replay: once only.2223## HTML2425```html26<h1 class="word-reveal" data-word-reveal>27 Build interfaces that feel calm, cinematic, and alive.28</h1>29```3031## CSS3233Keep no-JS content visible. Hide only after JavaScript is active and before the text has been split.3435```css36.word-reveal {37 visibility: visible;38}3940html.js .word-reveal[data-word-reveal]:not(.is-ready) {41 opacity: 0;42}4344.word-reveal__word {45 display: inline-block;46 opacity: 0;47 transform: translate3d(0, 20px, 0);48 transition:49 opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),50 transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);51 transition-delay: calc(var(--word-index) * 0.07s);52 will-change: opacity, transform;53}5455.word-reveal.is-visible .word-reveal__word {56 opacity: 1;57 transform: translate3d(0, 0, 0);58}5960@media (prefers-reduced-motion: reduce) {61 html.js .word-reveal[data-word-reveal]:not(.is-ready),62 .word-reveal__word {63 opacity: 1;64 transform: none;65 transition: none;66 }67}68```6970## JavaScript7172This splitter preserves spaces, avoids `innerHTML`, exposes the original sentence to screen readers, and unobserves after the first reveal.7374```js75document.documentElement.classList.add("js");7677function splitWordReveal(element) {78 if (element.dataset.wordRevealReady === "true") return;7980 const text = element.textContent || "";81 const parts = text.split(/(\s+)/);82 let wordIndex = 0;8384 element.textContent = "";85 element.setAttribute("aria-label", text.trim());8687 parts.forEach((part) => {88 if (!part.trim()) {89 element.appendChild(document.createTextNode(part));90 return;91 }9293 const word = document.createElement("span");94 word.className = "word-reveal__word";95 word.setAttribute("aria-hidden", "true");96 word.style.setProperty("--word-index", wordIndex);97 word.textContent = part;9899 element.appendChild(word);100 wordIndex += 1;101 });102103 element.dataset.wordRevealReady = "true";104 element.classList.add("is-ready");105}106107function initWordReveals(selector = "[data-word-reveal]") {108 const elements = Array.from(document.querySelectorAll(selector));109 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;110111 if (reduceMotion || !("IntersectionObserver" in window)) {112 elements.forEach((element) => {113 element.classList.add("is-ready", "is-visible");114 });115 return;116 }117118 const observer = new IntersectionObserver(119 (entries, io) => {120 entries.forEach((entry) => {121 if (!entry.isIntersecting) return;122123 entry.target.classList.add("is-visible");124 io.unobserve(entry.target);125 });126 },127 {128 threshold: 0.2,129 rootMargin: "0px 0px -10% 0px",130 }131 );132133 elements.forEach((element) => {134 splitWordReveal(element);135 observer.observe(element);136 });137}138139document.addEventListener("DOMContentLoaded", () => {140 initWordReveals();141});142```143144## Framework Notes145- React/Vue/Svelte: run the splitter after mount, then clean up observer instances on route changes.146- Framer Motion: keep the same tokens: `y: 20`, `opacity: 0`, duration `0.8`, ease `[0.16, 1, 0.3, 1]`, stagger `0.06` to `0.08`, `once: true`.147- GSAP: use `fromTo(words, { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, ease: "expo.out", stagger: 0.07 })`.148149## Taste Rules150- Use on short text: headlines, subheads, labels, and quotes. Avoid long paragraphs.151- Stagger words, not letters, for a calmer premium feel.152- Keep the offset subtle. Do not add bounce, rotation, or large blur.153- Animate `transform` and `opacity` only.154- Do not split text containing links, buttons, or meaningful inline markup.155- If wrapping is important, initialize after web fonts are ready.156157## Quick Checks158- Text is visible when JavaScript is disabled.159- Words begin at `translateY(20px)` and `opacity: 0`.160- Each word reveals once with a `0.06s` to `0.08s` delay.161- Repeated scrolling does not replay the animation.162- Reduced-motion users see static readable text.