Text Reveal
Reveal text word by word with staggered motion, preserving accessible reading order and respecting reduced motion.
Interactive demo
Motion design should feel intentional, accessible and alive.
What it does
Text appears word by word or line by line with staggered motion, drawing attention to headings or key messages.
When to use it
- Hero headlines that need a cinematic entrance.
- Pull quotes or testimonials.
- Section titles that should be revealed on scroll.
When to avoid it
- Body text where readability is more important than flair.
- Content that users need to read quickly.
- Screen-reader-first content where wrapping words can complicate semantics.
How it works
- Split the text into words or sentences using
Intl.Segmenteror a whitespace split. - Wrap each segment in a span with overflow hidden and inline-block display.
- Animate children — translateY from 100% to 0 and fade opacity.
- Stagger — use CSS custom properties or transition-delay per word.
- Trigger — start the animation on load, scroll or user interaction.
- Reduced motion — keep the full text visible with no wrapping animation.
Source code
<p class="text-reveal" data-reveal-text>
Motion design should feel intentional, accessible and alive.
</p> :root {
color-scheme: dark;
--bg: #0a0a0b;
--text: #f4f4f5;
}
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 2rem;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.text-reveal {
font-size: clamp(1.5rem, 5vw, 3rem);
font-weight: 700;
line-height: 1.2;
}
.text-reveal__word {
display: inline-block;
overflow: hidden;
vertical-align: bottom;
}
.text-reveal__inner {
display: inline-block;
transform: translateY(110%);
opacity: 0;
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 500ms cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: calc(var(--index, 0) * 80ms);
}
.text-reveal.is-revealed .text-reveal__inner {
transform: translateY(0);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.text-reveal__inner {
transform: none;
opacity: 1;
transition: none;
}
} function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function splitWords(text: string): string[] {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
return Array.from(segmenter.segment(text))
.filter((segment) => segment.isWordLike)
.map((segment) => segment.segment);
}
return text.trim().split(/\s+/);
}
const target = document.querySelector<HTMLElement>("[data-reveal-text]");
if (target) {
if (prefersReducedMotion()) {
target.classList.add("is-revealed");
} else {
const words = splitWords(target.textContent ?? "");
target.innerHTML = words
.map(
(word, index) =>
`<span class="text-reveal__word"><span class="text-reveal__inner" style="--index: ${index}">${word}</span></span>`
)
.join(" ");
target.classList.add("is-revealed");
}
}
export {}; Implementation recipe
Text Reveal Implementation Recipe
Goal
Reveal a headline word by word with staggered motion while preserving the original text for screen readers.
Requirements
- A text element.
- Word-level segmentation.
- Overflow-hidden wrappers and inner translating elements.
- Staggered timing via custom properties.
- Reduced-motion fallback.
Files to create
index.htmlstyles.csstext-reveal.ts
Step 1: Markup
Place the full headline inside a single element. The script will split it and re-inject markup.
Step 2: Split the text
Use Intl.Segmenter when available for proper word boundaries, falling back to whitespace splitting.
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const words = Array.from(segmenter.segment(text))
.filter((s) => s.isWordLike)
.map((s) => s.segment);
Step 3: Wrap each word
Wrap every word in two spans: an outer overflow: hidden inline block and an inner block that starts translated down.
<span class="word"><span class="inner" style="--index: 0">Motion</span></span>
Step 4: Animate with CSS transitions
Use a custom property to stagger each word.
.text-reveal__inner {
transform: translateY(110%);
opacity: 0;
transition: transform 600ms ease-out,
opacity 500ms ease-out;
transition-delay: calc(var(--index, 0) * 80ms);
}
.text-reveal.is-revealed .text-reveal__inner {
transform: translateY(0);
opacity: 1;
}
Step 5: Trigger the reveal
Add the is-revealed class after the DOM update. You can also trigger it on scroll or on user interaction.
Step 6: Reduced motion
Show the text immediately without splitting or translating.
Common pitfalls
- Removing the original text and replacing it with spans can change how screen readers pronounce content.
- Very long text splits create many DOM nodes and can hurt performance.
- Inline-block wrappers can introduce unwanted line-break behavior in some languages.
Verification checklist
- Words reveal in sequence with a staggered delay.
- Text is still selectable and readable.
- Reduced motion shows the full text immediately.
- Screen readers announce the content in the correct order.
- Fallback segmentation works when Intl.Segmenter is unavailable.
- Preserve the original text content for screen readers; do not remove it.
- Use aria-label on the wrapper if the split structure affects pronunciation.
- Avoid creating empty or invisible focusable elements.
- Disable motion and show text immediately under prefers-reduced-motion.
- Keep color contrast high on the revealed text.
- Use transform and opacity for each word's reveal.
- Avoid splitting huge paragraphs; prefer short headlines.
- Use CSS transitions rather than per-frame JavaScript.
- Cache DOM references to the wrapped segments.
- Consider using content-visibility for off-screen text blocks.
| API / Feature | Support | Fallback strategy |
|---|---|---|
Intl.Segmenter | Chrome 111+, Safari 16.4+, Firefox 125+ | Split on whitespace or sentence punctuation. |
CSS transitions | All modern browsers | Show final text state instantly. |
prefers-reduced-motion | All modern browsers | Instant full text visibility. |