Transcript — read-along
Variant: Read-along
Word-level highlighting synced to playback, timed the way speech actually is rather than on a metronome stagger. Function words get 100–160ms, "river" gets 460, a 400ms pause before the last phrase leaves no word lit at all — the wash is a claim that sound is happening — and "home." is held longest, the phrase-final lengthening every speaker does without noticing. One registered number is the playhead, in seconds of speech; each word reads it against its own start and end, so the wash, the ink that stays behind it, and the progress rule are three views of one clock and cannot drift a frame apart. Every ramp is linear because it rides playback, and an eased one would claim the voice sped up. Use it for audio transcripts, podcast players, and language-learning apps, where the eye needs to find the voice.
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>Transcript — read-along</title>
<style>
/* One registered number is the playhead, in seconds of speech. Every
word reads it against its own start and end, so the highlight, the
ink and the progress rule are three views of a single clock and
cannot drift a frame apart. */
@property --_now {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
: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;
--round: 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 — 6.5s: 0.4s of lead-in, 4.2s of speech played at exactly
one unit per second (6% → 70.6%), a held last frame, and the reset
inside a dark seam at 94%. */
--cycle: 6.5s;
--t: calc(var(--cycle) / var(--speed));
--_unread: color-mix(in srgb, var(--slate) 34%, transparent);
--_read: color-mix(in srgb, var(--slate) 88%, transparent);
--_wash: color-mix(in srgb, var(--clay) 20%, transparent);
--_muted: color-mix(in srgb, var(--slate) 55%, transparent);
}
* {
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);
color: var(--slate);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
}
.card {
width: min(340px, 92vw);
padding: clamp(14px, 3.4vw, 22px) clamp(15px, 3.6vw, 24px);
border: 1px solid var(--oat);
border-radius: calc(14px * var(--round));
background: var(--paper);
animation: play var(--t) infinite linear;
}
.eyebrow {
font-size: clamp(11px, 2.4vw, 12.5px);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--_muted);
}
.text {
margin-top: clamp(7px, 1.6vw, 11px);
font-size: clamp(15px, 4.2vw, 21px);
font-weight: 500;
line-height: 1.5;
letter-spacing: -0.01em;
}
.text,
.rule {
animation: seam var(--t) infinite;
}
.text p {
white-space: nowrap;
}
/* Spoken-ness ramps over 0.1s of speech; presence over 0.05s from
each edge of the word, so even a 100ms "the" reaches a full wash
and short function words never read as unstressed by accident. Both are linear on purpose — they ride a
playback clock, and an eased ramp would claim the voice sped up. */
.w {
--_spoken: clamp(0, (var(--_now) - var(--_s)) * 10, 1);
--_here: clamp(0, min(var(--_now) - var(--_s), var(--_e) - var(--_now)) * 20, 1);
position: relative;
isolation: isolate;
color: color-mix(in srgb, var(--_read) calc(var(--_spoken) * 100%), var(--_unread));
}
.w::before {
content: '';
position: absolute;
inset: 0.06em -0.16em;
z-index: -1;
border-radius: calc(5px * var(--round));
background: var(--_wash);
opacity: var(--_here);
}
.rule {
margin-top: clamp(10px, 2.2vw, 14px);
height: 2px;
border-radius: 999px;
background: var(--oat);
overflow: hidden;
}
.rule::after {
content: '';
display: block;
height: 100%;
background: var(--clay);
transform-origin: left;
transform: scaleX(calc(var(--_now) / 4.2));
}
/* Playback is a clock: linear, one unit per second at speed 1. */
@keyframes play {
0%,
6% {
--_now: 0;
}
70.6%,
93% {
--_now: 4.2;
}
94%,
100% {
--_now: 0;
}
}
@keyframes seam {
0%,
88% {
opacity: 1;
animation-timing-function: ease;
}
92%,
95% {
opacity: 0;
animation-timing-function: ease;
}
99%,
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
/* Ink and a wash, no travel: the piece keeps its meaning and just
plays slower. The rule's fill is the one transform, and it is a
readout, not a journey. */
.card,
.text,
.rule {
animation-duration: calc(var(--t) * 1.8);
}
}
</style>
</head>
<body>
<div
class="card"
role="img"
aria-label="A read-along transcript: 'The word for river is also the word for the way home.' Each word is highlighted as it is spoken, with uneven timing and a pause before the last phrase."
>
<p class="eyebrow">Read-along · 4s</p>
<div class="text">
<p>
<span class="w" style="--_s: 0; --_e: 0.16">The</span>
<span class="w" style="--_s: 0.18; --_e: 0.46">word</span>
<span class="w" style="--_s: 0.48; --_e: 0.62">for</span>
<span class="w" style="--_s: 0.64; --_e: 1.1">river</span>
</p>
<p>
<span class="w" style="--_s: 1.3; --_e: 1.42">is</span>
<span class="w" style="--_s: 1.44; --_e: 1.82">also</span>
<span class="w" style="--_s: 1.84; --_e: 1.96">the</span>
<span class="w" style="--_s: 1.98; --_e: 2.3">word</span>
</p>
<p>
<span class="w" style="--_s: 2.7; --_e: 2.84">for</span>
<span class="w" style="--_s: 2.86; --_e: 2.96">the</span>
<span class="w" style="--_s: 2.98; --_e: 3.3">way</span>
<span class="w" style="--_s: 3.32; --_e: 3.95">home.</span>
</p>
</div>
<div class="rule"></div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.