Back to the library

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.

textvoicecaptionsloop

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.