Transcript — interim to final
Variant: Interim to final
A live caption that shows its confidence in ink. Words arrive in grey with a ~130ms fade and no travel — a caption that slides is a caption that is harder to read mid-sentence — and stability is decided per word, not per line: "It’s hard to" has survived four more words of context, so it commits to full ink while the tail is still a guess. The wrong guess then sits on screen for a full 1.4s, long enough to be read and believed; the endpoint fires, the mic dot goes quiet, and a beat later the recogniser changes its mind: "wreck a nice beach" leaves in 160ms under a 2px blur, so two strings never read as one, and "recognize speech" arrives 60ms behind it — still grey, because a revision is not yet a promise — before committing a beat later. Both hypotheses share one grid cell at the end of the line, so nothing around the swap moves. Reach for it anywhere a transcript or a streaming answer can be revised, and the reader deserves to know which words are settled.
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 — interim to final</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 — 6.4s: words arrive 4-28%, the stable prefix commits at 28%,
the wrong tail is held for reading until the endpoint at 44%, is
revised by 51%, committed by 58%, and rests until the seam at 86%. */
--cycle: 6.4s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--_interim: color-mix(in srgb, var(--slate) 40%, transparent);
--_final: color-mix(in srgb, var(--slate) 88%, 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(380px, 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);
}
.eyebrow {
display: flex;
align-items: center;
gap: 0.55em;
font-size: clamp(11px, 2.4vw, 12.5px);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--_muted);
}
/* The mic dot is the only thing that says "listening" — it goes quiet
as the endpoint fires, a beat before the revision, because
finalisation is the recogniser deciding the speaker has stopped. */
.eyebrow i {
width: 0.62em;
height: 0.62em;
border-radius: 50%;
background: var(--clay);
animation: mic var(--t) infinite;
}
.line {
margin-top: clamp(8px, 1.8vw, 12px);
white-space: nowrap;
font-size: clamp(14px, 3.7vw, 19px);
line-height: 1.5;
letter-spacing: -0.005em;
animation: seam var(--t) infinite;
}
.w {
color: var(--_interim);
opacity: 0;
}
/* The revision cell: both hypotheses typeset in the same grid cell so
the swap is a crossfade in place — the words around it never move.
It sits at the end of the line, so its width change has no one to push. */
.tail {
display: inline-grid;
vertical-align: top;
}
.tail > * {
grid-area: 1 / 1;
}
.new {
color: var(--_interim);
opacity: 0;
animation:
revise-in var(--t) infinite,
commit-tail var(--t) infinite;
}
.old {
animation: revise-out var(--t) infinite;
}
/* Arrival is a fade with no travel: a caption that slides is a caption
that is harder to read, and the reader is mid-sentence. 120ms is the
shortest fade the eye still registers as arriving rather than blinking. */
.w:nth-child(1) {
animation:
arrive-1 var(--t) infinite,
commit-head var(--t) infinite;
}
.w:nth-child(2) {
animation:
arrive-2 var(--t) infinite,
commit-head var(--t) infinite;
}
.w:nth-child(3) {
animation:
arrive-3 var(--t) infinite,
commit-head var(--t) infinite;
}
.old .w:nth-child(1) {
animation: arrive-4 var(--t) infinite;
}
.old .w:nth-child(2) {
animation: arrive-5 var(--t) infinite;
}
.old .w:nth-child(3) {
animation: arrive-6 var(--t) infinite;
}
.old .w:nth-child(4) {
animation: arrive-7 var(--t) infinite;
}
@keyframes arrive-1 {
0%,
4% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
6%,
100% {
opacity: 1;
}
}
@keyframes arrive-2 {
0%,
8% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
10%,
100% {
opacity: 1;
}
}
@keyframes arrive-3 {
0%,
11% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
13%,
100% {
opacity: 1;
}
}
@keyframes arrive-4 {
0%,
16% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
18%,
100% {
opacity: 1;
}
}
@keyframes arrive-5 {
0%,
19% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
21%,
100% {
opacity: 1;
}
}
@keyframes arrive-6 {
0%,
22% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
24%,
100% {
opacity: 1;
}
}
@keyframes arrive-7 {
0%,
26% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
28%,
100% {
opacity: 1;
}
}
/* Stability is per word, not per line: "It's hard to" has survived
four more words of context and commits while the tail is still
interim. Ink is certainty — that is the whole vocabulary. */
@keyframes commit-head {
0%,
28% {
color: var(--_interim);
animation-timing-function: ease;
}
32%,
100% {
color: var(--_final);
}
}
/* The revision. The old hypothesis leaves in 160ms with a 2px blur —
blur masks the overlap so two strings never read as one — and the
new one arrives 60ms behind it, still interim grey: the recogniser
changed its mind, it has not yet promised anything. */
@keyframes revise-out {
0%,
46% {
opacity: 1;
filter: blur(0);
animation-timing-function: var(--ease-out);
}
48.5%,
100% {
opacity: 0;
filter: blur(2px);
}
}
@keyframes revise-in {
0%,
47% {
opacity: 0;
filter: blur(2px);
animation-timing-function: var(--ease-out);
}
51%,
100% {
opacity: 1;
filter: blur(0);
}
}
@keyframes commit-tail {
0%,
54% {
color: var(--_interim);
animation-timing-function: ease;
}
58%,
100% {
color: var(--_final);
}
}
@keyframes mic {
0%,
2% {
opacity: 0.35;
animation-timing-function: ease;
}
4%,
44% {
opacity: 1;
animation-timing-function: ease;
}
47%,
100% {
opacity: 0.35;
}
}
/* The reset hides in a dark window; a final line visibly un-committing
would read as the transcript being retracted. */
@keyframes seam {
0%,
86% {
opacity: 1;
animation-timing-function: ease;
}
91%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* Nothing here travels; the piece is opacity and ink, so it keeps
every beat and only slows down. The blur is dropped — animated
values beat static ones, so it needs its own opacity-only keys. */
.card .eyebrow i,
.card .line,
.card .line .w,
.card .line .old .w,
.card .old,
.card .new {
animation-duration: calc(var(--t) * 1.8);
}
.card .old {
animation-name: revise-out-rm;
}
.card .new {
animation-name: revise-in-rm, commit-tail;
}
@keyframes revise-out-rm {
0%,
46% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
48.5%,
100% {
opacity: 0;
}
}
@keyframes revise-in-rm {
0%,
47% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
51%,
100% {
opacity: 1;
}
}
}
</style>
</head>
<body>
<div class="card">
<p class="eyebrow"><i></i>Live captions</p>
<p
class="line"
role="img"
aria-label="A live caption arriving in grey as 'It's hard to wreck a nice beach', then revised to 'It's hard to recognize speech' and committed in full ink."
>
<span class="w">It’s</span> <span class="w">hard</span> <span class="w">to</span>
<span class="tail"
><span class="old"
><span class="w">wreck</span> <span class="w">a</span> <span class="w">nice</span>
<span class="w">beach</span></span
><span class="new">recognize speech</span></span
>
</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.