Back to the library

Voice — note playback

Variant: Note playback

A voice message playing back: the playhead crosses the waveform at constant speed because playback is a clock, and an eased sweep would claim the middle of the message played faster than its ends. Played and unplayed are one set of bars in two colours — the lit row is a clipped copy driven by the same linear clock as the head, so the wipe and the position can never drift a frame apart. The bars themselves hold still, since the recording already happened and bars that dance during playback claim a live signal. The rewind is the one eased move in the loop: 210ms, a gesture rather than a datum. Reach for it in chat threads and inboxes, where a voice note needs to show its progress without a timestamp doing all the work.

voicedatagestureloop

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>Voice — note playback</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)
        );

        --_muted: color-mix(in srgb, var(--slate) 55%, transparent);

        /* Clock — 5.2s: playback sweeps for 70% of it, holds a beat at the
           end, rewinds in 210ms, and rests before replaying. The sweep is
           linear because playback is a clock; the rewind is the one eased
           move in the loop — a thing already on screen travelling home, so
           it gets the ease-in-out that on-screen moves get. */
        --cycle: 5.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .player {
        display: flex;
        align-items: center;
        gap: 14px;
        padding: 13px 18px;
        border-radius: calc(14px * var(--round));
        background: var(--paper);
        border: 1px solid var(--oat);
      }

      .transport {
        position: relative;
        width: 16px;
        height: 16px;
        flex: none;
      }

      .transport svg {
        position: absolute;
        inset: 0;
      }

      .tri {
        fill: var(--clay-d);
        animation: tri var(--t) ease infinite;
      }

      .bars {
        fill: var(--clay-d);
        animation: bars var(--t) ease infinite;
      }

      /* Sequenced swap: the outgoing glyph is gone before the incoming one
         arrives, so the two never share a frame at half weight. */
      @keyframes tri {
        0%,
        4% {
          opacity: 1;
        }
        5%,
        75.4% {
          opacity: 0;
        }
        76.4%,
        100% {
          opacity: 1;
        }
      }

      @keyframes bars {
        0%,
        5.4% {
          opacity: 0;
        }
        6.4%,
        74% {
          opacity: 1;
        }
        75%,
        100% {
          opacity: 0;
        }
      }

      .wave {
        position: relative;
        width: 118px;
        height: 26px;
      }

      .row {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        gap: 2px;
      }

      .row i {
        flex: none;
        width: 3px;
        border-radius: 1.5px;
        background: var(--oat);
      }

      /* Played and unplayed are one waveform in two colours: the lit row is a
         clipped copy of the dim one, driven by the same linear clock as the
         playhead — one clock, two renderings, so the wipe and the head can
         never drift a frame apart. */
      .row.lit i {
        background: var(--clay);
      }

      .row.lit {
        clip-path: inset(0 118px 0 0);
        animation: wipe var(--t) linear infinite;
      }

      @keyframes wipe {
        0%,
        4% {
          clip-path: inset(0 118px 0 0);
        }
        74%,
        80% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: var(--ease-in-out);
        }
        84%,
        100% {
          clip-path: inset(0 118px 0 0);
        }
      }

      .head {
        position: absolute;
        top: -2px;
        left: -1px;
        width: 2px;
        height: 30px;
        border-radius: 1px;
        background: var(--slate);
        animation: head var(--t) linear infinite;
      }

      @keyframes head {
        0%,
        4% {
          transform: translateX(0);
        }
        74%,
        80% {
          transform: translateX(118px);
          animation-timing-function: var(--ease-in-out);
        }
        84%,
        100% {
          transform: translateX(0);
        }
      }

      /* n heights, one voice: a swell into the middle of the phrase and a
         trail at the end. Static on purpose — the recording already happened,
         and bars that dance during playback claim a signal that is not
         playing. */
      .row i:nth-child(1) {
        height: 7px;
      }
      .row i:nth-child(2) {
        height: 12px;
      }
      .row i:nth-child(3) {
        height: 18px;
      }
      .row i:nth-child(4) {
        height: 14px;
      }
      .row i:nth-child(5) {
        height: 20px;
      }
      .row i:nth-child(6) {
        height: 24px;
      }
      .row i:nth-child(7) {
        height: 16px;
      }
      .row i:nth-child(8) {
        height: 9px;
      }
      .row i:nth-child(9) {
        height: 13px;
      }
      .row i:nth-child(10) {
        height: 21px;
      }
      .row i:nth-child(11) {
        height: 25px;
      }
      .row i:nth-child(12) {
        height: 18px;
      }
      .row i:nth-child(13) {
        height: 11px;
      }
      .row i:nth-child(14) {
        height: 7px;
      }
      .row i:nth-child(15) {
        height: 10px;
      }
      .row i:nth-child(16) {
        height: 16px;
      }
      .row i:nth-child(17) {
        height: 20px;
      }
      .row i:nth-child(18) {
        height: 14px;
      }
      .row i:nth-child(19) {
        height: 9px;
      }
      .row i:nth-child(20) {
        height: 12px;
      }
      .row i:nth-child(21) {
        height: 17px;
      }
      .row i:nth-child(22) {
        height: 11px;
      }
      .row i:nth-child(23) {
        height: 8px;
      }
      .row i:nth-child(24) {
        height: 6px;
      }

      .time {
        font-size: 12px;
        font-weight: 600;
        font-variant-numeric: tabular-nums;
        color: var(--_muted);
      }

      @media (prefers-reduced-motion: reduce) {
        /* The wipe is progress and stays, on a 1.6x clock. The playhead is
           pure travel and goes — the boundary between clay and oat is already
           the position. */
        .row.lit,
        .tri,
        .bars {
          animation-duration: calc(var(--t) * 1.6);
        }

        .head {
          animation: none;
          opacity: 0;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="player"
      role="img"
      aria-label="A voice note playing: a playhead sweeps the waveform at constant speed, bars turn clay as they are heard, then the head rewinds and it plays again"
    >
      <span class="transport">
        <svg viewBox="0 0 16 16" aria-hidden="true">
          <path class="tri" d="M4 2.5 L13.5 8 L4 13.5 Z" />
        </svg>
        <svg viewBox="0 0 16 16" aria-hidden="true">
          <g class="bars">
            <rect x="3.5" y="2.5" width="3.4" height="11" rx="1.2" />
            <rect x="9.1" y="2.5" width="3.4" height="11" rx="1.2" />
          </g>
        </svg>
      </span>
      <span class="wave">
        <span class="row">
          <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i
          ><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
        </span>
        <span class="row lit" aria-hidden="true">
          <i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i
          ><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
        </span>
        <span class="head"></span>
      </span>
      <span class="time">0:07</span>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.