| name | mengto-staggered-word-reveal |
| description | 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. |
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 += ;
});
element.. = ;
element..();
}
() {
elements = .(.(selector));
reduceMotion = .().;
(reduceMotion || !( )) {
elements.( {
element..(, );
});
;
}
observer = (
{
entries.( {
(!entry.) ;
entry...();
io.(entry.);
});
},
{
: ,
: ,
}
);
elements.( {
(element);
observer.(element);
});
}
.(, {
();
});
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.