Back to the library

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.

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 — 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>