Back to the library

Voice — speaking halo

Variant: Speaking halo

A call tile whose halo is a level, not a clock: it swells with each syllable, dies with the voice, and spends half the loop silent. That is the whole difference from a beacon — a ring leaving on a fixed period claims a process, this claims a person. The envelope is asymmetric the way a voice is, ~120ms attacks against releases up to two and a half times longer, and the SPEAKING label underneath is the voice-activity detector’s binary verdict on the same signal: it opens a beat behind the first syllable — detection has latency — and hangs on ~400ms after the level dies, the same hangover a real VAD uses so nobody is dropped at every comma. Reach for it on call rosters and voice rooms, where "who is talking" is the page’s most-read datum.

voicepresencestatusloop

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 — speaking halo</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)
        );

        /* Clock — 4.8s: two phrases of speech and half the cycle in silence.
           The silence is load-bearing — a halo that never rests is a beacon
           on a timer, and the whole claim here is that it is not one. */
        --cycle: 4.8s;
        --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);
      }

      .tile {
        display: grid;
        justify-items: center;
        gap: 10px;
      }

      .seat {
        position: relative;
        width: 56px;
        height: 56px;
      }

      /* The halo is a level, not a clock: it swells with each syllable and dies
         with the voice. It never detaches from the avatar and travels outward —
         a ring that leaves on a fixed period is a beacon, and a beacon claims a
         process where this claims a person. */
      .halo {
        position: absolute;
        inset: -7px;
        border-radius: 50%;
        border: 2px solid var(--olive);
        opacity: 0;
        transform: scale(0.86);
        animation: level var(--t) linear infinite;
      }

      .face {
        position: absolute;
        inset: 0;
        display: grid;
        place-items: center;
        border-radius: 50%;
        background: color-mix(in srgb, var(--olive) 16%, var(--paper));
        box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--olive) 40%, transparent);
        font-size: 15px;
        font-weight: 600;
        color: color-mix(in srgb, var(--olive) 68%, var(--slate));
      }

      /* The envelope: syllabic swells with a ~120ms attack and releases up to
         two and a half times longer, longest at phrase ends — that asymmetry
         is what a voice actually does: onset is nearly instant, decay trails.
         Symmetric swells read as breathing. Timing function is linear on the
         container; each segment carries its own curve. */
      @keyframes level {
        0%,
        3% {
          opacity: 0;
          transform: scale(0.86);
          animation-timing-function: var(--ease-out);
        }
        5.5% {
          opacity: 0.9;
          transform: scale(1);
          animation-timing-function: ease;
        }
        9% {
          opacity: 0.35;
          transform: scale(0.92);
          animation-timing-function: var(--ease-out);
        }
        11.5% {
          opacity: 1;
          transform: scale(1.04);
          animation-timing-function: ease;
        }
        16% {
          opacity: 0.4;
          transform: scale(0.93);
          animation-timing-function: var(--ease-out);
        }
        18.5% {
          opacity: 0.85;
          transform: scale(0.99);
          animation-timing-function: ease;
        }
        24.5% {
          opacity: 0;
          transform: scale(0.86);
        }
        /* Breath between phrases — real, but shorter than the tail silence. */
        29%,
        30% {
          opacity: 0;
          transform: scale(0.86);
          animation-timing-function: var(--ease-out);
        }
        32.5% {
          opacity: 0.95;
          transform: scale(1.02);
          animation-timing-function: ease;
        }
        36% {
          opacity: 0.4;
          transform: scale(0.92);
          animation-timing-function: var(--ease-out);
        }
        38.5% {
          opacity: 0.9;
          transform: scale(1);
          animation-timing-function: ease;
        }
        42% {
          opacity: 0.55;
          transform: scale(0.95);
          animation-timing-function: var(--ease-out);
        }
        44.5% {
          opacity: 0.8;
          transform: scale(0.98);
          animation-timing-function: ease;
        }
        51%,
        100% {
          opacity: 0;
          transform: scale(0.86);
        }
      }

      .name {
        font-size: 13px;
        font-weight: 600;
        letter-spacing: 0.01em;
      }

      /* The word is the voice-activity detector's verdict where the halo is
         the raw level: binary, opens a beat behind the first syllable —
         detection has latency, not clairvoyance — and hangs on ~400ms after
         the level dies, the same hangover a real VAD uses so a speaker is
         not dropped at every comma. */
      .state {
        font-size: 11px;
        font-weight: 600;
        letter-spacing: 0.04em;
        text-transform: uppercase;
        color: color-mix(in srgb, var(--olive) 75%, var(--slate));
        animation: verdict var(--t) ease infinite;
      }

      @keyframes verdict {
        0%,
        4% {
          opacity: 0;
        }
        6.5%,
        53% {
          opacity: 1;
        }
        59.5%,
        100% {
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The swell goes, the brightness stays: the halo becomes a pure
           opacity envelope on a 1.6x clock, which still reads as a voice
           rising and falling — the scale was texture, the light was signal. */
        .halo {
          animation: level-dim calc(var(--t) * 1.6) linear infinite;
          transform: none;
        }

        .state {
          animation-duration: calc(var(--t) * 1.6);
        }

        @keyframes level-dim {
          0%,
          3% {
            opacity: 0;
          }
          5.5% {
            opacity: 0.8;
          }
          9% {
            opacity: 0.35;
          }
          11.5% {
            opacity: 0.9;
          }
          16% {
            opacity: 0.4;
          }
          18.5% {
            opacity: 0.75;
          }
          24.5% {
            opacity: 0;
          }
          29%,
          30% {
            opacity: 0;
          }
          32.5% {
            opacity: 0.85;
          }
          36% {
            opacity: 0.4;
          }
          38.5% {
            opacity: 0.8;
          }
          42% {
            opacity: 0.5;
          }
          44.5% {
            opacity: 0.7;
          }
          51%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="tile"
      role="img"
      aria-label="A call tile: a halo around the avatar swells and dies with the rhythm of a voice, and a Speaking label holds through the pauses"
    >
      <span class="seat">
        <span class="halo"></span>
        <span class="face">AN</span>
      </span>
      <span class="name">Ana</span>
      <span class="state">Speaking</span>
    </div>
  </body>
</html>

The variant family

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