Back to the library

Voice — mute is a cut

Variant: Mute cut

The action costs 30ms and the state gets 2.5s, which is the right budget for a control whose job is to be believed. Three sound arcs flicker at different rates while live — three thresholds, so the fan is a crude level meter rather than decoration — and all three die on the same frame the slash starts drawing, because a mute is a switch and a level that fades out claims the voice trailed off on its own. The slash takes 150ms with a paper casing under it, so it reads as cutting through the mic rather than lying on top. Use it wherever muting has social consequences — calls, recording booths, live rooms — and the user needs the state to be unmistakable at a glance.

voicecontrolstatussvgloop

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 — mute is a cut</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;
        --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 — 6s: live until 44%, muted until 86%, then unmuted for the
           tail so the loop closes on sound. The mute itself spends 30ms; the
           muted state gets 2.5s, because the state is what the animation is
           about and the action is only its edge. */
        --cycle: 6s;
        --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);
      }

      .rig {
        display: grid;
        justify-items: center;
        gap: 12px;
      }

      .stage {
        display: flex;
        align-items: center;
        gap: 6px;
      }

      .btn {
        display: grid;
        place-items: center;
        width: 56px;
        height: 56px;
        border-radius: 50%;
        background: var(--paper);
        border: 1px solid var(--oat);
        animation: press var(--t) ease infinite;
      }

      /* Two 120ms dips, one per press. The button is the only thing that
         moves on the action frames — everything else changes state, which is
         the division of labour a real control has: the finger gets motion,
         the system gets a cut. */
      @keyframes press {
        0%,
        43.7% {
          transform: scale(1);
        }
        44.7% {
          transform: scale(0.96);
        }
        45.7%,
        85.7% {
          transform: scale(1);
        }
        86.7% {
          transform: scale(0.96);
        }
        87.7%,
        100% {
          transform: scale(1);
        }
      }

      .glyph {
        width: 24px;
        height: 24px;
        overflow: visible;
      }

      .mic {
        stroke: var(--slate);
        stroke-width: 1.7;
        stroke-linecap: round;
        fill: none;
        animation: mic-dim var(--t) ease infinite;
      }

      @keyframes mic-dim {
        0%,
        45% {
          opacity: 1;
        }
        45.5%,
        86% {
          opacity: 0.42;
        }
        88.5%,
        100% {
          opacity: 1;
        }
      }

      /* The slash draws in 150ms — deliberate, visible, the one flourish. The
         paper casing under it is what makes it read as cutting through the
         mic rather than lying on top of it. */
      .slash,
      .slash-under {
        fill: none;
        stroke-linecap: round;
        stroke-dasharray: 24;
        stroke-dashoffset: 24;
        animation: slash var(--t) linear infinite;
      }

      .slash-under {
        stroke: var(--paper);
        stroke-width: 5;
      }

      .slash {
        stroke: var(--rust);
        stroke-width: 2;
      }

      @keyframes slash {
        0%,
        45% {
          stroke-dashoffset: 24;
          animation-timing-function: var(--ease-out);
        }
        47.5%,
        86% {
          stroke-dashoffset: 0;
          animation-timing-function: ease;
        }
        88.5%,
        100% {
          stroke-dashoffset: 24;
        }
      }

      .arcs {
        width: 16px;
        height: 26px;
        overflow: visible;
      }

      .arcs path {
        fill: none;
        stroke: var(--olive);
        stroke-width: 2;
        stroke-linecap: round;
      }

      /* Three arcs, three thresholds: the inner one lights for any sound, the
         outer only on peaks — so the fan flickering at different rates is a
         crude level meter, not decoration. All three die on the same 30ms
         frame at 45%: a mute is a switch, and a level that fades out claims
         the voice trailed off on its own. */
      .a1 {
        animation: a1 var(--t) ease infinite;
      }
      .a2 {
        animation: a2 var(--t) ease infinite;
      }
      .a3 {
        animation: a3 var(--t) ease infinite;
      }

      @keyframes a1 {
        0% {
          opacity: 0.85;
        }
        7% {
          opacity: 0.3;
        }
        13% {
          opacity: 0.9;
        }
        22% {
          opacity: 0.4;
        }
        30% {
          opacity: 0.85;
        }
        38%,
        44.5% {
          opacity: 0.6;
        }
        45%,
        88% {
          opacity: 0.12;
        }
        91% {
          opacity: 0.8;
        }
        96% {
          opacity: 0.4;
        }
        100% {
          opacity: 0.85;
        }
      }

      @keyframes a2 {
        0% {
          opacity: 0.5;
        }
        8% {
          opacity: 0.1;
        }
        14% {
          opacity: 0.7;
        }
        24% {
          opacity: 0.15;
        }
        31% {
          opacity: 0.6;
        }
        39%,
        44.5% {
          opacity: 0.25;
        }
        45%,
        88% {
          opacity: 0.08;
        }
        92% {
          opacity: 0.55;
        }
        100% {
          opacity: 0.5;
        }
      }

      @keyframes a3 {
        0%,
        12% {
          opacity: 0.08;
        }
        15% {
          opacity: 0.5;
        }
        19% {
          opacity: 0.08;
        }
        30% {
          opacity: 0.08;
        }
        32.5% {
          opacity: 0.45;
        }
        36%,
        44.5% {
          opacity: 0.08;
        }
        45%,
        88% {
          opacity: 0.05;
        }
        93% {
          opacity: 0.35;
        }
        100% {
          opacity: 0.08;
        }
      }

      .lbl {
        display: grid;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.01em;
        justify-items: center;
      }

      .lbl span {
        grid-area: 1 / 1;
      }

      .on {
        color: var(--_muted);
        animation: lbl-on var(--t) ease infinite;
      }

      .off {
        color: var(--rust);
        animation: lbl-off var(--t) ease infinite;
      }

      /* Sequenced, not symmetric: the outgoing word is gone before the
         incoming one arrives, so the two never sit at half weight on the
         same frame. */
      @keyframes lbl-on {
        0%,
        45% {
          opacity: 1;
        }
        46%,
        87.8% {
          opacity: 0;
        }
        89%,
        100% {
          opacity: 1;
        }
      }

      @keyframes lbl-off {
        0%,
        45.8% {
          opacity: 0;
        }
        47.2%,
        86.4% {
          opacity: 1;
        }
        87.4%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The press dip goes; the cut, the slash, and the label swap stay —
           they are colour and opacity, and they are the entire meaning. One
           shared 1.5x clock keeps the cut landing on the same frame
           everywhere. */
        .btn {
          animation: none;
        }

        .mic,
        .slash,
        .slash-under,
        .a1,
        .a2,
        .a3,
        .on,
        .off {
          animation-duration: calc(var(--t) * 1.5);
        }
      }
    </style>
  </head>
  <body>
    <div
      class="rig"
      role="img"
      aria-label="A microphone button: sound arcs flicker while live, then a press cuts them dead on one frame as a slash draws through the mic"
    >
      <span class="stage">
        <span class="btn">
          <svg class="glyph" viewBox="0 0 24 24" aria-hidden="true">
            <g class="mic">
              <rect x="9.2" y="3" width="5.6" height="10" rx="2.8" />
              <path d="M5.5 10.5a6.5 6.5 0 0 0 13 0" />
              <path d="M12 17v3" />
            </g>
            <path class="slash-under" d="M5 5 L19 19" />
            <path class="slash" d="M5 5 L19 19" />
          </svg>
        </span>
        <svg class="arcs" viewBox="0 0 16 26" aria-hidden="true">
          <path class="a1" d="M3 10 A5 5 0 0 1 3 16" />
          <path class="a2" d="M6 7 A8.5 8.5 0 0 1 6 19" />
          <path class="a3" d="M9 4 A12 12 0 0 1 9 22" />
        </svg>
      </span>
      <span class="lbl">
        <span class="on">Mic on</span>
        <span class="off">Muted</span>
      </span>
    </div>
  </body>
</html>

The variant family

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