Back to the library

Overtone listening indicator, hairline

Variant: Hairline

The light end of the overtone listening indicator: 1px strings, about half the amplitude, and faded upper harmonics. Use it inside dense toolbars or next to a text field, where the mic state should be visible without pulling focus. Timing and frequency ratios match the other weights exactly. The only changes are stroke weight, how far the strings travel and how opaque they are, so the upper harmonics sit almost at the edge of what you notice.

voicelisteningstatusminimalsvg

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>Overtone listening indicator — Hairline</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; /* include ONLY if at least one border-radius is wrapped below */
  --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
     (#d97757 / #b85c3e / #788c5d / #b04a3f). */
  --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 — every duration in the document runs off --t. */
  --cycle: 2.4s; /* the authored master loop */
  --t: calc(var(--cycle) / var(--speed));

  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* include only if used */

  --_stroke: 1px;
  --_amp: 0.55;
  --_o1: 0.8;
  --_o2: 0.55;
  --_o3: 0.4;
  --_drone: 0.6;
}

* { box-sizing: border-box; }

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

.card {
  width: min(340px, 88vw);
  padding: clamp(16px, 5vw, 24px);
  background: var(--paper);
  border: 1px solid var(--oat);
  border-radius: calc(18px * var(--round));
  display: grid;
  gap: clamp(10px, 3vw, 14px);
  justify-items: center;
  text-align: center;
}

.wave-svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

.amp {
  transform-box: view-box;
  transform-origin: 120px 40px;
  transform: scaleY(var(--_amp));
}

.drone {
  stroke: var(--oat);
  stroke-width: var(--_stroke);
  stroke-linecap: round;
  opacity: var(--_drone);
  vector-effect: non-scaling-stroke;
}

/* Non-scaling stroke keeps the line full weight as the wave flattens through its node. */
.h path {
  fill: none;
  stroke-width: var(--_stroke);
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
  transform-box: view-box;
  transform-origin: 120px 40px;
  animation: harmonic var(--t) linear infinite;
}

.h1 { opacity: var(--_o1); }
.h2 { opacity: var(--_o2); }
.h3 { opacity: var(--_o3); }

.h1 path { stroke: var(--clay); }
/* True overtones: 2x and 3x the fundamental, so all three re-align every cycle. */
.h2 path { stroke: var(--rust); animation-duration: calc(var(--t) / 2); }
.h3 path { stroke: var(--olive); animation-duration: calc(var(--t) / 3); }

/* Cosine sampled every 30deg; linear between samples is visually a sine. */
@keyframes harmonic {
  0%, 100% { transform: scaleY(1); }
  8.333%   { transform: scaleY(0.866); }
  16.667%  { transform: scaleY(0.5); }
  25%      { transform: scaleY(0.02); }
  33.333%  { transform: scaleY(-0.5); }
  41.667%  { transform: scaleY(-0.866); }
  50%      { transform: scaleY(-1); }
  58.333%  { transform: scaleY(-0.866); }
  66.667%  { transform: scaleY(-0.5); }
  75%      { transform: scaleY(-0.02); }
  83.333%  { transform: scaleY(0.5); }
  91.667%  { transform: scaleY(0.866); }
}

@keyframes breathe {
  0%, 100% { opacity: 0.45; }
  50%      { opacity: 1; }
}

.label { display: grid; gap: 2px; }
.title { margin: 0; font-size: clamp(14px, 4vw, 16px); font-weight: 600; letter-spacing: 0.01em; }
.meta { margin: 0; font-size: clamp(12px, 3.4vw, 13px); opacity: 0.62; }

@media (max-width: 380px) {
  .card { padding: 14px 16px; gap: 10px; }
}

@media (prefers-reduced-motion: reduce) {
  .h path {
    transform: none;
    animation: breathe calc(var(--t) * 2) var(--ease-in-out) infinite;
  }
  .h2 path { animation-delay: calc(-1.6s / var(--speed)); }
  .h3 path { animation-delay: calc(-3.2s / var(--speed)); }
}
</style>
</head>
<body>
  <div class="card" role="status" aria-live="polite">
    <svg class="wave-svg" viewBox="-8 0 256 80" aria-hidden="true">
      <g class="amp">
        <line class="drone" x1="0" y1="40" x2="240" y2="40"/>
        <g class="h h3"><path d="M0 40 Q40 20 80 40 T160 40 T240 40"/></g>
        <g class="h h2"><path d="M0 40 Q60 10 120 40 T240 40"/></g>
        <g class="h h1"><path d="M0 40 Q120 -20 240 40"/></g>
      </g>
    </svg>
    <div class="label">
      <p class="title">Listening</p>
      <p class="meta">Say a command</p>
    </div>
  </div>
</body>
</html>

The variant family

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