Back to the library

Voice — level meter, peak hold

Variant: Level meter

A mic-check meter with real ballistics: ~84ms attacks so no transient is missed, ~250-290ms releases so the eye can read a value sound never holds still — the asymmetry every hardware meter standard writes down. The pips are a clipped copy of a dim row driven on steps() sized to each move, so a pip is only ever fully lit or fully dark and the boundary always lands on a gap; a top pip at half opacity would claim a level between the meter’s own graduations. The peak lamp rides each new maximum, holds 1.4s so the loudest moment can be read after it is gone, then falls on an ease-in, because a dropped marker accelerates. It latches rust only in the last 21ms before the ceiling. Use it on input pickers and pre-call checks, where "is this thing on" needs an answer before anyone speaks.

voicedatastatusloop

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 — level meter with peak hold</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 — 4.2s: a noise floor, one loud passage that touches the
           ceiling once, a decay, and a murmur. The peak lamp holds 1.4s —
           long enough to be read after the sound that caused it is gone,
           which is the only job a peak-hold has. */
        --cycle: 4.2s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 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);
      }

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

      .mic {
        width: 18px;
        height: 18px;
        flex: none;
        stroke: color-mix(in srgb, var(--slate) 62%, transparent);
        stroke-width: 1.6;
        stroke-linecap: round;
        fill: none;
      }

      .track {
        position: relative;
        width: 116px;
        height: 16px;
      }

      .row {
        position: absolute;
        inset: 0;
        display: flex;
        gap: 4px;
      }

      .row span {
        width: 6px;
        height: 16px;
        border-radius: calc(2px * var(--round));
        background: var(--clay);
        opacity: 0.14;
      }

      /* The lit row is a clipped copy of the dim one, so a pip is either fully
         lit or fully dark and the boundary is always on a gap — a meter whose
         top pip sits at half opacity is claiming a level between its own
         graduations. The top two pips are rust: the clipping zone, lit only
         by the one over-loud peak in the loop. */
      .row.lit {
        clip-path: inset(0 100px 0 0);
        animation: level var(--t) linear infinite;
      }

      .row.lit span {
        opacity: 1;
      }

      .row.lit span:nth-child(n + 11) {
        background: var(--rust);
      }

      /* Fast attack, slow release — the asymmetry every real meter ballistics
         standard writes down: ~84ms up so no transient is missed, ~250-290ms
         down so the eye can read a value sound never holds still. Every
         segment runs on steps() sized to its travel, so the lit edge lands
         only on pip boundaries even between keyframes — a meter that slid
         smoothly would claim levels between its own graduations. */
      @keyframes level {
        0%,
        6% {
          clip-path: inset(0 100px 0 0);
          animation-timing-function: steps(5);
        }
        8% {
          clip-path: inset(0 50px 0 0);
          animation-timing-function: steps(2);
        }
        14% {
          clip-path: inset(0 70px 0 0);
          animation-timing-function: steps(5);
        }
        16% {
          clip-path: inset(0 20px 0 0);
          animation-timing-function: steps(4);
        }
        22.5% {
          clip-path: inset(0 60px 0 0);
          animation-timing-function: steps(6);
        }
        24.5% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: steps(4);
        }
        31% {
          clip-path: inset(0 40px 0 0);
          animation-timing-function: steps(1);
        }
        33% {
          clip-path: inset(0 30px 0 0);
          animation-timing-function: steps(5);
        }
        40% {
          clip-path: inset(0 80px 0 0);
          animation-timing-function: steps(2);
        }
        47%,
        66% {
          clip-path: inset(0 100px 0 0);
          animation-timing-function: steps(3);
        }
        68% {
          clip-path: inset(0 70px 0 0);
          animation-timing-function: steps(2);
        }
        74% {
          clip-path: inset(0 90px 0 0);
          animation-timing-function: steps(2);
        }
        76% {
          clip-path: inset(0 70px 0 0);
          animation-timing-function: steps(3);
        }
        83%,
        100% {
          clip-path: inset(0 100px 0 0);
        }
      }

      /* The peak lamp: rides each new maximum so it lands as the level does,
         sits through the hold, then falls — ease-in, because a falling marker
         is a dropped thing and gravity accelerates. The rust flips on only in
         the final 21ms before the ceiling — a latch, not a warm-up — and
         stays rust until the lamp lets go: a clip indicator that resets the
         instant the sound stops is one nobody ever saw. */
      .peak {
        position: absolute;
        top: -2px;
        left: 0;
        width: 2px;
        height: 20px;
        border-radius: 1px;
        background: var(--slate);
        animation: peak var(--t) linear infinite;
      }

      @keyframes peak {
        0%,
        6% {
          transform: translateX(14px);
          background: var(--slate);
          animation-timing-function: var(--ease-out);
        }
        8%,
        14% {
          transform: translateX(64px);
          background: var(--slate);
          animation-timing-function: var(--ease-out);
        }
        16%,
        22.5% {
          transform: translateX(94px);
          background: var(--slate);
          animation-timing-function: var(--ease-out);
        }
        /* Background-only keyframe: holds the lamp slate until 21ms before
           the ceiling, so the rust is a latch rather than a fade-in. */
        24% {
          background: var(--slate);
        }
        24.5%,
        57.5% {
          transform: translateX(114px);
          background: var(--rust);
          animation-timing-function: cubic-bezier(0.55, 0, 0.85, 0.36);
        }
        65.5%,
        66% {
          transform: translateX(14px);
          background: var(--slate);
          animation-timing-function: var(--ease-out);
        }
        68%,
        93.5% {
          transform: translateX(44px);
          background: var(--slate);
          animation-timing-function: cubic-bezier(0.55, 0, 0.85, 0.36);
        }
        100% {
          transform: translateX(14px);
          background: var(--slate);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The level is the datum, so the clipped row keeps running on a 1.7x
           clock. The peak lamp is pure travel and goes; the rust pips still
           report the clip, so nothing the meter was saying is lost. */
        .row.lit {
          animation-duration: calc(var(--t) * 1.7);
        }

        .peak {
          animation: none;
          opacity: 0;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="meter"
      role="img"
      aria-label="A microphone level meter: pips light with the voice, and a peak-hold lamp sticks at the loudest moment before falling"
    >
      <svg class="mic" viewBox="0 0 18 18" aria-hidden="true">
        <rect x="6.4" y="1.5" width="5.2" height="9" rx="2.6" />
        <path d="M3.5 8.5a5.5 5.5 0 0 0 11 0" />
        <path d="M9 14v2.5" />
      </svg>
      <span class="track">
        <span class="row">
          <span></span><span></span><span></span><span></span><span></span><span></span><span></span
          ><span></span><span></span><span></span><span></span><span></span>
        </span>
        <span class="row lit" aria-hidden="true">
          <span></span><span></span><span></span><span></span><span></span><span></span><span></span
          ><span></span><span></span><span></span><span></span><span></span>
        </span>
        <span class="peak"></span>
      </span>
    </div>
  </body>
</html>

The variant family

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