Transcript — roll-up captions
Variant: Roll-up
Two-line broadcast-style roll-up: words land whole and instantly on the bottom line, and when it fills, both lines travel up one row in 350ms of ease-in-out while the top line leaves through a 0.3em feather. Arrival is a cut because a caption is read the moment it exists — a fade would spend the first 100ms of every word half-legible — and the roll is the one eased move because it is the one thing already being read while it travels. The face is monospaced, as broadcast captions are, so the reveal snaps to word boundaries as exact ch counts on a step-end clip, and the text is circular: after four rolls the stack is back where it began, so the loop has no seam to hide. Reach for it for live transcripts, meeting captions, and logs where the newest line matters and the previous one is context.
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 — roll-up captions</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;
--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 — 10s, four lines of 2.5s each: words land in the first 70%
of a line's quarter, and the roll takes the last 350ms of it. The
text is circular, so after four rolls the stack is back where it
started and the loop has no seam to hide. */
--cycle: 10s;
--t: calc(var(--cycle) / var(--speed));
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--_caption: color-mix(in srgb, var(--ivory) 94%, transparent);
--_muted: color-mix(in srgb, var(--slate) 55%, transparent);
--_lh: 1.6em;
}
* {
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 {
display: grid;
justify-items: start;
gap: clamp(8px, 1.8vw, 12px);
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);
}
.eyebrow {
font-size: clamp(11px, 2.4vw, 12.5px);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--_muted);
}
.box {
padding: clamp(8px, 2vw, 12px) clamp(11px, 2.8vw, 16px);
border-radius: calc(6px * var(--round));
background: var(--slate);
color: var(--_caption);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: clamp(13px, 3.6vw, 18px);
}
/* Two rows tall. The top edge is feathered over the first 0.3em —
inside the line's own leading, so a resting line is untouched and
only a line on its way out loses its head first. */
.win {
width: 22ch;
height: calc(var(--_lh) * 2);
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, transparent, var(--slate) 0.3em);
mask-image: linear-gradient(to bottom, transparent, var(--slate) 0.3em);
}
/* The roll is the one eased move: a line that is already being read
travels up one row, so it gets ease-in-out, and 350ms — long enough
to track with the eye mid-sentence, short enough to be done before
the next word lands. */
.stack {
animation: roll var(--t) infinite var(--ease-in-out);
}
.row {
height: var(--_lh);
line-height: var(--_lh);
white-space: pre;
}
/* Words arrive whole and instantly. A caption is read the moment it
exists; a fade would spend the first 100ms of every word half-legible.
The clip snaps to word boundaries in ch — the face is monospaced, as
broadcast captions are, so a boundary is an exact count. */
.row.b,
.row.c,
.row.d,
.row.a2 {
clip-path: inset(-0.3em 100% -0.3em 0);
}
.row.b {
animation: type-b var(--t) infinite step-end;
}
.row.c {
animation: type-c var(--t) infinite step-end;
}
.row.d {
animation: type-d var(--t) infinite step-end;
}
.row.a2 {
animation: type-a var(--t) infinite step-end;
}
@keyframes roll {
0%,
21.5% {
transform: translateY(0);
}
25%,
46.5% {
transform: translateY(calc(var(--_lh) * -1));
}
50%,
71.5% {
transform: translateY(calc(var(--_lh) * -2));
}
75%,
96.5% {
transform: translateY(calc(var(--_lh) * -3));
}
100% {
transform: translateY(calc(var(--_lh) * -4));
}
}
/* "has its own name, and" */
@keyframes type-b {
0% {
clip-path: inset(-0.3em 100% -0.3em 0);
}
3% {
clip-path: inset(-0.3em calc(100% - 3ch) -0.3em 0);
}
6.5% {
clip-path: inset(-0.3em calc(100% - 7ch) -0.3em 0);
}
9.5% {
clip-path: inset(-0.3em calc(100% - 11ch) -0.3em 0);
}
12.5% {
clip-path: inset(-0.3em calc(100% - 17ch) -0.3em 0);
}
17%,
100% {
clip-path: inset(-0.3em calc(100% - 21ch) -0.3em 0);
}
}
/* "the names outlive the" */
@keyframes type-c {
0%,
27.5% {
clip-path: inset(-0.3em 100% -0.3em 0);
}
28% {
clip-path: inset(-0.3em calc(100% - 3ch) -0.3em 0);
}
32% {
clip-path: inset(-0.3em calc(100% - 9ch) -0.3em 0);
}
36.5% {
clip-path: inset(-0.3em calc(100% - 17ch) -0.3em 0);
}
40%,
100% {
clip-path: inset(-0.3em calc(100% - 21ch) -0.3em 0);
}
}
/* "bends the river makes." */
@keyframes type-d {
0%,
52.5% {
clip-path: inset(-0.3em 100% -0.3em 0);
}
53% {
clip-path: inset(-0.3em calc(100% - 5ch) -0.3em 0);
}
57% {
clip-path: inset(-0.3em calc(100% - 9ch) -0.3em 0);
}
60% {
clip-path: inset(-0.3em calc(100% - 15ch) -0.3em 0);
}
63.5%,
100% {
clip-path: inset(-0.3em calc(100% - 22ch) -0.3em 0);
}
}
/* "Each bend of the river" — after a full stop, so the first word
waits a breath longer than the others do. */
@keyframes type-a {
0%,
77.5% {
clip-path: inset(-0.3em 100% -0.3em 0);
}
79.5% {
clip-path: inset(-0.3em calc(100% - 4ch) -0.3em 0);
}
83% {
clip-path: inset(-0.3em calc(100% - 9ch) -0.3em 0);
}
86% {
clip-path: inset(-0.3em calc(100% - 12ch) -0.3em 0);
}
89% {
clip-path: inset(-0.3em calc(100% - 16ch) -0.3em 0);
}
92.5%,
100% {
clip-path: inset(-0.3em calc(100% - 22ch) -0.3em 0);
}
}
@media (prefers-reduced-motion: reduce) {
/* The roll becomes a cut — the lines jump a row instead of travelling
— and the whole clock slows. Words still land one at a time. */
.stack {
animation-timing-function: step-end;
}
.stack,
.row.b,
.row.c,
.row.d,
.row.a2 {
animation-duration: calc(var(--t) * 1.6);
}
}
</style>
</head>
<body>
<div class="card">
<p class="eyebrow">CC · Roll-up</p>
<div
class="box"
role="img"
aria-label="Two-line roll-up captions. Words arrive one at a time on the bottom line; when it fills, both lines roll up and the top line leaves. Text: Each bend of the river has its own name, and the names outlive the bends the river makes."
>
<div class="win">
<div class="stack">
<div class="row">Each bend of the river</div>
<div class="row b">has its own name, and</div>
<div class="row c">the names outlive the</div>
<div class="row d">bends the river makes.</div>
<div class="row a2">Each bend of the river</div>
</div>
</div>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.