Staggered character reveal
Every glyph rises out of its own mask, 26ms apart. The interesting decisions are all in the splitting: per word so the text still wraps, and one aria-label so a screen reader does not read it letter by letter.
How it works
- The mask is per word, not per line. A line-level mask looks identical until the copy reflows at a narrower width — then the mask is still the old line's height and the text is cut in half. This is the single most common way this effect ships broken.
- Each character is
translateY(110%)inside its word'soverflow: hiddenbox. 110% rather than 100% covers the line-height slack, so nothing peeks above the mask before it starts. - The word mask gets
padding-bottom: 0.16em; margin-bottom: -0.16em. Without it the mask is sized to the line box and shaves the descenders off g, y and p — a clipping you will stare at for a while before you see it. - The stagger is
transition-delay: calc(var(--i) * 26ms)with the index written on each span. Under 20ms the line reads as one block; over about 40ms the last word arrives late enough that the eye has already moved on. - Easing is
cubic-bezier(0.16, 1, 0.3, 1)over 620ms — a hard deceleration. Opacity runs on a much shorter 300ms linear ramp, so glyphs are fully opaque well before they stop moving; matching the two durations makes the text look like it is fading rather than arriving. - Splitting text into dozens of spans destroys it for assistive tech. The parent gets
aria-labelwith the original sentence and the pieces arearia-hidden, putting it back to one string. - Replay removes the class, forces a reflow with
void document.body.offsetWidth, then re-adds it. Without that read the two class changes coalesce into no change at all and the replay silently does nothing.
Numbers
- Stagger
- 26ms per character
- Duration
- 620ms
- Easing
- cubic-bezier(0.16, 1, 0.3, 1)
- Travel
- translateY(110%) → 0
- Opacity
- 300ms linear (shorter on purpose)
- Descender pad
- 0.16em
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a staggered character reveal in vanilla HTML/CSS/JS. Split the text into WORDS first and characters inside each word — never split the whole string into characters, or a line break can land mid-word. Wrap each word in a span with overflow: hidden, display: inline-block, vertical-align: top, plus padding-bottom: 0.16em and margin-bottom: -0.16em so descenders are not clipped by the mask. Each character span starts at translateY(110%) and opacity 0; when the parent gets a .go class they animate to none/1 with transition: transform 620ms cubic-bezier(0.16, 1, 0.3, 1), opacity 300ms linear and transition-delay: calc(var(--i) * 26ms), where --i is the running character index. Keep the opacity ramp deliberately shorter than the movement. Set aria-label on the parent to the original sentence and aria-hidden on every generated span, so screen readers read one string rather than individual letters. Trigger on IntersectionObserver at threshold 0.4. For replay, remove the class, force a reflow by reading document.body.offsetWidth, then re-add it. Under prefers-reduced-motion: reduce, render the text in its final state with no transition.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staggered character reveal</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh; display: grid; place-items: center;
padding: 0 8%;
background: #eef1f4;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
color: #14161a;
}
h1 { font-size: clamp(22px, 5.2vw, 46px); font-weight: 700; letter-spacing: -0.03em; line-height: 1.18; }
.sub { margin-top: 14px; font-size: clamp(12px, 1.8vw, 15px); color: #5c626b; line-height: 1.6; }
/* Each word is its own mask. Masking per word rather than per line lets the
text wrap normally — a single line-level mask breaks the moment the copy
reflows at a narrower width, which is where this effect usually dies. */
.w {
display: inline-block; overflow: hidden;
vertical-align: top;
/* Descenders (g, y, p) get clipped by a mask sized to the line box, so the
mask is padded and pulled back by the same amount. */
padding-bottom: 0.16em; margin-bottom: -0.16em;
}
.c {
display: inline-block;
transform: translateY(110%);
opacity: 0;
}
.go .c {
transform: none; opacity: 1;
transition:
transform 620ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 300ms linear;
transition-delay: calc(var(--i) * 26ms);
}
.rerun {
position: fixed; right: 14px; bottom: 14px;
font: 500 11.5px/1 -apple-system, sans-serif; color: #5c626b;
background: #ffffffcc; border: 1px solid #00000014; border-radius: 99px;
padding: 7px 13px; cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.c { transform: none; opacity: 1; }
.go .c { transition: none; }
}
</style>
<div>
<h1 data-split>Immersive entertainment that did not exist before</h1>
<p class="sub" data-split>Staggered character reveal · each glyph rises out of its own mask</p>
</div>
<button class="rerun" id="rerun">Replay</button>
<script>
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
function split(el) {
// Words first, characters inside them. Splitting the whole string into
// characters and letting them wrap would let a line break land mid-word.
const words = el.textContent.split(/(\s+)/);
el.textContent = "";
let n = 0;
for (const word of words) {
if (/^\s+$/.test(word)) { el.appendChild(document.createTextNode(word)); continue; }
const w = document.createElement("span");
w.className = "w";
for (const ch of word) {
const c = document.createElement("span");
c.className = "c";
c.style.setProperty("--i", n++);
c.textContent = ch;
w.appendChild(c);
}
el.appendChild(w);
}
// The visible text is now split across dozens of spans, which a screen
// reader would announce one letter at a time. One aria-label on the parent
// and the pieces hidden puts it back to a single sentence.
el.setAttribute("aria-label", el.textContent);
[...el.querySelectorAll(".w")].forEach((w) => w.setAttribute("aria-hidden", "true"));
return el;
}
const targets = [...document.querySelectorAll("[data-split]")].map(split);
const play = () => {
targets.forEach((t) => t.classList.remove("go"));
// Force a reflow so removing and re-adding the class in the same frame
// actually restarts the transition instead of being coalesced away.
void document.body.offsetWidth;
targets.forEach((t) => t.classList.add("go"));
};
if (reduce) targets.forEach((t) => t.classList.add("go"));
else {
const io = new IntersectionObserver((es) => {
if (es.some((e) => e.isIntersecting)) { play(); io.disconnect(); }
}, { threshold: 0.4 });
io.observe(targets[0]);
}
document.getElementById("rerun").addEventListener("click", play);
</script>Where it breaks
- 26ms × character count is the real duration. A 60-character headline takes 620ms + 1.56s before the last glyph lands — fine for a hero, far too slow for anything a user is waiting on. Stagger by word for long copy.
- The split runs on
textContent, so any inline markup inside the heading (a link, a<br>, an<em>) is destroyed. Walk text nodes instead if the copy is authored by anyone but you. - If a web font swaps in after the split, every word mask was measured against the fallback metrics and the glyphs jump. Split after
document.fonts.readywhen the headline uses a custom face.
Seen in
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:47