Back to the library
Text reveal — word by word
Variant: Word
Nine words at 40ms, indexed across the whole block rather than restarting per line, so the wave crosses the breaks diagonally instead of running four times. That is the trade: the sentence reads as assembling, and the clean left edge is gone. Words are the smallest unit still worth staggering in body copy — below this the reader is watching machinery rather than reading.
textentrancestagger
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Text reveal — word by word</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — every duration in the document runs off --t. */
--cycle: 6s;
--t: calc(var(--cycle) / var(--speed));
/* Asymmetric: off the mark immediately, then decelerating for most of
its length. Type has to look like it settled onto the baseline
rather than stopped at it. */
--ease-accent: cubic-bezier(0.2, 0.72, 0.12, 1);
/* Travel, not a duration — scales with the type rather than the clock. */
--_rise: clamp(5px, 1.3vw, 12px);
--_step: 0.04s;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(12px, 3vw, 28px);
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.reveal {
width: fit-content;
max-width: 100%;
animation: cycle var(--t) linear infinite;
}
/* The block is dark for the first 13% of every cycle. That window is not
a pause — it is where the units snap back to their start pose out of
sight, so the cascade never plays in reverse. It has to outlast the
longest stagger in the family (660ms), and every sibling carries the
same 13% so the four can be compared frame for frame. The exit is one
uniform fade: a staggered exit would be a second performance. */
@keyframes cycle {
0%,
13% {
opacity: 0;
}
14%,
95% {
opacity: 1;
animation-timing-function: ease;
}
98%,
100% {
opacity: 0;
}
}
.copy {
display: grid;
justify-items: start;
}
.line {
/* Lines are hard-broken, not wrapped, so all four siblings break in
exactly the same places at every stage width. */
white-space: nowrap;
}
.h {
font-size: clamp(19px, 4.5vw, 34px);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.16;
}
.accent {
color: var(--clay);
}
.b {
font-size: clamp(12.5px, 2.95vw, 22px);
line-height: 1.42;
color: color-mix(in srgb, var(--slate) 58%, transparent);
}
.h + .b {
margin-top: clamp(8px, 1.2vw, 14px);
}
.w {
/* Transforms need a block-level box; inline boxes ignore them. */
display: inline-block;
opacity: 0;
transform: translateY(var(--_rise));
animation: rise var(--t) linear infinite;
animation-delay: calc(var(--_i) * var(--_step) / var(--speed));
}
/* 40ms a word, indexed across the whole block rather than restarting
per line, so the wave crosses the line breaks diagonally instead of
re-running four times. */
@keyframes rise {
0%,
15% {
opacity: 0;
transform: translateY(var(--_rise));
animation-timing-function: var(--ease-accent);
}
21%,
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The travel goes; the stagger stays, because the
order things arrive in is meaning rather than decoration. The cycle
stretches 1.7x, which only widens the reset window the offsets have
to fit inside. */
.reveal {
animation-duration: calc(var(--t) * 1.7);
}
.w {
transform: none;
animation-name: rise-rm;
animation-duration: calc(var(--t) * 1.7);
}
@keyframes rise-rm {
0%,
15% {
opacity: 0;
animation-timing-function: ease;
}
21%,
100% {
opacity: 1;
}
}
}
</style>
</head>
<body>
<div class="reveal">
<div
class="copy"
role="img"
aria-label="Every deploy, in one timeline. Builds, migrations and incidents."
>
<p class="line h">
<span class="w" style="--_i: 0">Every</span>
<span class="w" style="--_i: 1">deploy,</span>
</p>
<p class="line h accent">
<span class="w" style="--_i: 2">in</span> <span class="w" style="--_i: 3">one</span>
<span class="w" style="--_i: 4">timeline.</span>
</p>
<p class="line b">
<span class="w" style="--_i: 5">Builds,</span>
<span class="w" style="--_i: 6">migrations</span>
</p>
<p class="line b">
<span class="w" style="--_i: 7">and</span>
<span class="w" style="--_i: 8">incidents.</span>
</p>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.