# Noise meter

> A stealth HUD dial whose violet trace ripples calmly while you sneak and spikes and glows when you are loud.

The small dark dial a game shows while the player sneaks, so they can tell at a glance whether they will be heard. Quiet is a low, calm ripple; moving adds a livelier wobble; loud fills the dial with tall jagged spikes and a violet glow, with a quick punch as it turns loud. A thump (a landing, a dropped item) spikes it for a moment, and standing up hides it. Use it bare in a HUD of your own, or with its chips to drive it.

A React component by Bruno Delorence: https://brunodelorence.com/components/noise-meter. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Games · HUD.

## Anatomy

1. **Stage**: The dial centred above its chips, on the page’s own background (or a plain light or dark surface when `colorScheme` sets one). It never goes under 320 px tall.
2. **Dial**: The `role="meter"` box, `size` px wide (144 by default, the kit draws it at 50), with a soft drop shadow so it lifts off a light page. It pops when the noise turns loud and shrinks away when hidden.
3. **Bloom**: A soft violet glow behind the trace, faded in through `--glow` only once the level passes a walk.
4. **Wave**: One path rebuilt every frame: a slow sine ripple plus a row of spikes that scrolls in from the right, tapered towards the rim.
5. **Halo**: A blurred copy of the wave under the sharp one (the wave itself is never blurred), shining with the glow and fading a little slower than the level.
6. **Rim**: A half-unit edge around the disc: darker on light, a faint light line on dark, so the dial keeps its outline on any background.
7. **Level chips**: A `radiogroup` of Quiet, Moving and Loud in one neutral pill; arrow keys, Home and End move the pick.
8. **Sneak**: A toggle chip (`aria-pressed`) that brings the dial up or puts it away, as crouching and standing do.
9. **Thump**: Fires a one-off spike: the level jumps to full, holds 180 ms, then fades over the current state.

## When to use

- A stealth readout in a game HUD: how much noise the player makes while sneaking, read at a glance from the corner of the eye.
- A level that changes many times a second and only needs to be felt, not read as a number: quiet, moving or loud.
- A signal that should only be on screen while it matters: the dial comes up while sneaking and goes away on standing up.
- One-off bursts on top of a steady level, such as a landing or a dropped item, fired by counting up `burst`.
- A live audio or activity cue in a game-like interface, where a glowing trace fits the art.

## When not to use

- A level the reader must read exactly or compare. Use a bar or a gauge with a number and a scale.
- A microphone or volume input in a settings screen. Use a slider with a live level bar.
- A health, stamina or charge resource that empties and refills. Use a gauge or a wheel built for it.
- A history of a signal over time. Use a sparkline or a chart, which keep the past on screen.
- A calm product interface. The scrolling, glowing trace pulls the eye; use a status dot or a badge.

## Installation

Install it with the shadcn CLI, into components/noise-meter of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/noise-meter.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { useState } from 'react';
import { NoiseMeter, type NoiseState } from './noise-meter';

export function StealthHud({ speed, sneaking }: { speed: number; sneaking: boolean }) {
  const state: NoiseState = speed > 6 ? 'loud' : speed > 0.5 ? 'moving' : 'quiet';
  const [landings, setLandings] = useState(0);
  // Call setLandings((n) => n + 1) when the player lands a jump.
  return <NoiseMeter bare size={64} state={state} shown={sneaking} burst={landings} />;
}
```

## States

- **Quiet**: `state="quiet"`: a dim, calm violet ripple and no glow.
- **Moving**: `state="moving"`: small spikes wobble across the ripple; still no glow.
- **Loud**: `state="loud"`: tall jagged spikes, a brighter trace and the violet bloom.
- **Exact level**: `level={0.72}` over `state="moving"`: spikes most of the way up and the glow just coming in.
- **Hidden**: `shown={false}`: the dial is gone and `aria-hidden`; its frame loop stops until it comes back.
- **Bare**: `bare`: just the dial, with no chips, to place in a HUD of your own.
- **Kit size**: `size={50}`: the three states at the kit’s own 50 px.
- **Light**: `colorScheme="light"`: light chips on a plain light surface. The dial stays dark, as on a game HUD, lifted by its drop shadow.
- **Dark**: `colorScheme="dark"`: dark chips on a plain dark surface; the dial keeps its outline through its faint light rim.

## How it works

- The dial is one SVG in a 50-unit box (the size of the dial in the kit), so it draws crisp at any size. The trace is a single path rebuilt every frame: a slow sine ripple, always there, plus a row of spike samples, up and down in turn, that scrolls in from the right like an oscilloscope.
- The samples keep only their shape (a ragged height and a slow swell that leaves gaps between bursts); the noise level of the moment scales the whole row, so the trace answers within a frame or two. The path has a point on every sample, so no spike loses its tip, and the spikes taper off towards the rim.
- The level moves like an audio meter: a 50 ms attack and a 450 ms release. A thump holds it at full for 180 ms, then lets it fade over about a second.
- The glow is a blurred copy of the trace, under the sharp one, plus a soft violet bloom behind it; the trace itself is never blurred. Both read one custom property, --glow, which follows the level but falls slower (700 ms), so a warning lingers a beat after the noise stops.
- Turning loud, or a thump, gives the dial a punch played by the Web Animations API: a dip to 95%, a pop to 107% and a settle, in 420 ms. Showing and hiding the dial are CSS transitions on scale and opacity, with an overshoot on the way in and none on the way out.
- The frame loop runs only while the dial is shown, on screen and in a visible tab. While untouched, a short sneak plays on its own: crouch, creep, walk, creep, run, walk, land, stand up.

## API reference

- **state** (`'quiet' | 'moving' | 'loud'`): How loud the player is. Set it to control the meter (the demo is then off); without it the meter keeps its own, driven by its controls.
- **defaultState** (`'quiet' | 'moving' | 'loud'`, default `'quiet'`): The state an uncontrolled meter starts in.
- **onStateChange** (`(state: NoiseState) => void`): Called when the reader picks a state with the controls.
- **level** (`number`): A precise noise level from 0 to 1, overriding the state’s own (0.06, 0.46 and 1): spikes start above 0.1 and the glow above 0.5.
- **shown** (`boolean`): Whether the dial is up (the player is sneaking). Set it to control it.
- **defaultShown** (`boolean`, default `true`): Whether an uncontrolled dial starts up.
- **onShownChange** (`(shown: boolean) => void`): Called when the reader shows or hides the dial with the controls.
- **burst** (`number`, default `0`): Count it up to fire a one-off spike, like a landing or a dropped item: full noise for 180 ms, then a fade.
- **size** (`number`, default `144`): The dial’s width in px; the kit draws it at 50. Everything inside scales with it.
- **bare** (`boolean`, default `false`): Just the dial: no controls, to place in a HUD of your own.
- **label** (`string`, default `'Noise'`): The meter’s accessible name, also used in its announcements.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a short sneak plays on its own. Off when state or shown is set, and in bare mode.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark chips (and a plain surface of that scheme behind them), or the reader’s system setting. Without it the component follows the page’s color-scheme. The dial is the same on both, as a game HUD is: a dark disc with a soft drop shadow and a faint rim, so it reads on either.
- **className** (`string`): Extra class for the root.

## Accessibility

- The dial is a meter (role="meter") named by label, with its value as a percentage and its state as text (“Quiet”, “Moving”, “Loud”). When hidden it is also hidden from screen readers.
- The levels are a radio group: Tab reaches the checked one, arrow keys, Home and End move and choose. Sneak is a toggle button (aria-pressed) and Thump a plain button; all have a visible focus ring.
- Changes the reader makes are announced in a polite live region (“Noise: loud”, “Noise meter hidden”); the demo’s own changes are not, so it never chatters.
- The drawing is decorative (aria-hidden). Under reduced motion nothing scrolls or pops: each state shows a still drawing, changes are instant, a thump shows the loud drawing for 600 ms, and the demo never plays.

## Source

### noise-meter/noise-meter.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { useAutoplay } from './use-autoplay';
import styles from './noise-meter.module.css';

/*
 * Noise meter: the stealth readout of a game HUD. While the player sneaks, a small dark dial shows
 * how much noise they make: a calm violet ripple when quiet, a livelier wobble when moving, and
 * tall jagged spikes with a violet glow when loud. Standing up hides it.
 *
 * - The dial is one SVG in a 50-unit box (the kit's 50 px), drawn at any size. The wave is a
 *   single path rebuilt every frame from two parts: a slow sine ripple that is always there, and
 *   a row of spike samples, alternately up and down, that scrolls in from the right like an
 *   oscilloscope trace. The noise level scales the whole row at once, so the trace answers within
 *   a frame or two.
 * - The level moves like an audio meter: a fast attack (50 ms) and a slow release (450 ms). A thump
 *   holds it at full for 180 ms, then lets it fade. The spikes taper towards the rim.
 * - The glow is a blurred copy of the wave, drawn under the sharp one, plus a soft violet bloom
 *   behind it; both follow the level, a little slower on the way out (700 ms), through --glow,
 *   written per frame. The wave itself is never blurred.
 * - The dial carries its own edge, so it reads on any page: a faint rim and a soft drop shadow.
 * - Going loud (or a thump) gives the dial a punch: a short dip, a pop and a settle, played by the
 *   Web Animations API. Showing and hiding are CSS transitions, with an overshoot on the way in.
 * - The frame loop only runs while the dial is shown, on screen and in a visible tab. Under
 *   reduced motion nothing scrolls: each state shows a still drawing, and changes are instant.
 * - While on screen and untouched, a short sneak plays on its own (crouch, creep, walk, run, land,
 *   stand up); any real input hands the controls to the reader.
 */

export type NoiseState = 'quiet' | 'moving' | 'loud';

const STATES: NoiseState[] = ['quiet', 'moving', 'loud'];
const NAMES: Record<NoiseState, string> = { quiet: 'Quiet', moving: 'Moving', loud: 'Loud' };
/** The noise level each state settles on, from 0 to 1. */
const LEVELS: Record<NoiseState, number> = { quiet: 0.06, moving: 0.46, loud: 1 };

/** The drawing's box, in units: the kit's dial is 50 px across. */
const BOX = 50;
const MID = BOX / 2;
/** Spike samples are this many units apart; up and down in turn, so a peak every 3.5 units. */
const STEP = 1.75;
const SAMPLES = Math.ceil(BOX / STEP) + 2;
/** The tallest spike, in units from the middle line (the kit's loud dial peaks about 20 up). */
const PEAK = 21;
/** The quiet ripple: its height and wavelength in units (about nine waves across the dial). */
const RIPPLE = 1.7;
const WAVELENGTH = 5.6;
/** How fast the trace scrolls, and the ripple drifts, in units per second. */
const SCROLL = 34;
const DRIFT = 7;
/** The level's attack and release time constants, in seconds. */
const ATTACK = 0.05;
const RELEASE = 0.45;
/** A thump holds the level at full for THUMP_HOLD seconds, then fades with this time constant. */
const THUMP_HOLD = 0.18;
const THUMP_FADE = 0.22;
/** The glow's own rise and fall, in seconds: slower than the level, so a burst keeps its shine. */
const GLOW_IN = 0.1;
const GLOW_OUT = 0.7;

/** How much of the level becomes spikes: none while quiet, all of it when loud. */
const spikeShare = (level: number) => Math.max(0, (level - 0.1) / 0.9) ** 1.15;
/** The glow, from nothing at a walk to full when loud. */
const glowShare = (level: number) => {
  const t = Math.min(1, Math.max(0, (level - 0.5) / 0.42));
  return t * t * (3 - 2 * t);
};

/** A small seeded random source, so the still drawings are the same on server and client. */
function random(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * One spike sample's shape, from -1 to 1: its sign alternates, its height is a little ragged and
 * swells and dips slowly, so a steady noise still reads as bursts with gaps between them. The
 * noise level of the moment scales the whole row, so the trace answers at once.
 */
function sample(index: number, time: number, rand: () => number) {
  const sign = index % 2 ? -1 : 1;
  const swell = 0.675 + 0.325 * Math.sin(time * 5.1 + Math.sin(time * 1.7) * 2);
  return sign * (0.6 + 0.4 * rand()) * swell;
}

/**
 * The wave's path: the ripple plus the spike samples, read at `shift` units of scroll, scaled by
 * the level and tapered towards the rim.
 */
function wavePath(samples: number[], shift: number, phase: number, level: number) {
  const ripple = RIPPLE * (1 - 0.55 * spikeShare(level));
  const height = PEAK * spikeShare(level);
  let d = '';
  // Every half unit for the ripple, and exactly on every sample, so no spike loses its tip.
  let fine = 0;
  let k = Math.ceil(shift / STEP);
  for (let x = 0; x <= BOX;) {
    const at = (x + shift) / STEP;
    const i = Math.floor(at);
    const f = at - i;
    const spike = height * ((samples[i] ?? 0) * (1 - f) + (samples[i + 1] ?? 0) * f);
    const taper = Math.min(1, 1.5 * Math.sin((Math.PI * x) / BOX)) ** 1.5;
    const wobble = 0.82 + 0.18 * Math.sin(x * 0.61 + phase * 0.4);
    const y =
      MID + ripple * wobble * Math.sin(((x + phase) / WAVELENGTH) * Math.PI * 2) + spike * taper;
    d += `${d ? 'L' : 'M'}${x.toFixed(2)} ${y.toFixed(2)}`;
    const onSample = k * STEP - shift;
    if (Math.abs(x - onSample) < 1e-6) k += 1;
    if (Math.abs(x - fine) < 1e-6) fine += 0.5;
    x = Math.min(fine, k * STEP - shift, x < BOX ? BOX : Infinity);
  }
  return d;
}

/** The still drawing of a state: what reduced motion and the first paint show. */
function stillSamples(state: NoiseState) {
  const rand = random(state === 'loud' ? 7 : 3);
  return Array.from({ length: SAMPLES }, (_, i) => {
    const v = sample(i, 0, rand) / 0.675;
    if (state !== 'loud') return v * 0.85;
    const sign = i % 2 ? -1 : 1;
    // The kit's loud dial: a tall burst left of the middle, a dip, a smaller burst on the right.
    const x = i * STEP;
    const lobes =
      Math.exp(-(((x - 18.5) / 9.5) ** 2)) +
      0.25 * Math.exp(-(((x - 30) / 5) ** 2)) +
      0.68 * Math.exp(-(((x - 40) / 5) ** 2));
    return sign * (0.8 + 0.2 * rand()) * Math.min(1, lobes);
  });
}

const still = (state: NoiseState) => wavePath(stillSamples(state), 0, 0, LEVELS[state]);

/** The demo: a short sneak, step by step, each held for `ms`. */
const SCRIPT: { state: NoiseState; shown: boolean; ms: number; thump?: boolean }[] = [
  { state: 'quiet', shown: false, ms: 900 },
  { state: 'quiet', shown: true, ms: 1800 },
  { state: 'moving', shown: true, ms: 2200 },
  { state: 'quiet', shown: true, ms: 1100 },
  { state: 'loud', shown: true, ms: 1900 },
  { state: 'moving', shown: true, ms: 1000 },
  { state: 'quiet', shown: true, ms: 700 },
  { state: 'quiet', shown: true, ms: 1900, thump: true },
  { state: 'quiet', shown: false, ms: 600 },
];

export function NoiseMeter({
  state,
  defaultState = 'quiet',
  onStateChange,
  level,
  shown,
  defaultShown = true,
  onShownChange,
  burst = 0,
  size = 144,
  bare = false,
  label = 'Noise',
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** How loud the player is. Set it to control the meter; without it the meter keeps its own. */
  state?: NoiseState;
  /** The state an uncontrolled meter starts in. */
  defaultState?: NoiseState;
  /** Called when the reader picks a state with the controls. */
  onStateChange?: (state: NoiseState) => void;
  /** A precise noise level from 0 to 1, overriding the state's own (0.06, 0.46 and 1). */
  level?: number;
  /** Whether the dial is up (the player is sneaking). Set it to control it. */
  shown?: boolean;
  /** Whether an uncontrolled dial starts up. */
  defaultShown?: boolean;
  /** Called when the reader shows or hides the dial with the controls. */
  onShownChange?: (shown: boolean) => void;
  /** Change it (count it up) to fire a one-off spike, like a landing or a dropped item. */
  burst?: number;
  /** The dial's width in px; the kit draws it at 50. */
  size?: number;
  /** Just the dial: no controls, to place in a HUD of your own. */
  bare?: boolean;
  /** The meter's accessible name. */
  label?: string;
  /** While on screen and untouched, a short sneak plays on its own. Off when state is set. */
  autoplay?: boolean;
  /** Light or dark chips and surface, or the reader's ('system'); without it, the page's. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, visible, reduced, take } = useAutoplay<HTMLDivElement>(5000);
  const [ownState, setOwnState] = useState<NoiseState>(defaultState);
  const [ownShown, setOwnShown] = useState(defaultShown);
  const [demo, setDemo] = useState<{ step: number; thumps: number } | null>(null);
  const [ownThumps, setOwnThumps] = useState(0);
  const [message, setMessage] = useState('');
  /** Under reduced motion a thump shows the loud drawing for a moment instead of a burst. */
  const [flash, setFlash] = useState(false);
  const dial = useRef<HTMLDivElement>(null);
  const wave = useRef<SVGPathElement>(null);
  const glowWave = useRef<SVGPathElement>(null);
  const radios = useRef<(HTMLButtonElement | null)[]>([]);
  const ids = useId();

  const demoOn = autoplay && playing && state === undefined && shown === undefined && !bare;
  const step = demoOn && demo ? SCRIPT[demo.step] : null;
  const current = state ?? step?.state ?? ownState;
  const isShown = shown ?? step?.shown ?? ownShown;
  const target = flash ? 1 : (level ?? LEVELS[current]);
  const thumps = burst + ownThumps + (demo?.thumps ?? 0);

  // The demo's clock: one step after another, round and round.
  useEffect(() => {
    if (!demoOn) {
      setDemo(null);
      return;
    }
    let index = 0;
    let count = 0;
    let timer = 0;
    const run = () => {
      const now = SCRIPT[index];
      if (now.thump) count += 1;
      setDemo({ step: index, thumps: count });
      timer = window.setTimeout(() => {
        index = (index + 1) % SCRIPT.length;
        run();
      }, now.ms);
    };
    run();
    return () => window.clearTimeout(timer);
  }, [demoOn]);

  // What the frame loop reads; kept in refs so the loop never restarts for a new level.
  const live = useRef({
    target,
    level: target,
    kick: 0,
    hold: 0,
    glow: glowShare(target),
    samples: stillSamples(current),
    shift: 0,
    phase: 0,
    index: 0,
    time: 0,
    rand: random(11),
  });
  live.current.target = target;

  // A dip, a pop and a settle, when the noise turns loud or a thump lands.
  const punch = useEffectEvent((strength: number) => {
    if (reduced || !dial.current?.animate) return;
    const up = 1 + 0.07 * strength;
    dial.current.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.95)', offset: 0.18 },
        { transform: `scale(${up})`, offset: 0.45 },
        { transform: 'scale(0.99)', offset: 0.75 },
        { transform: 'scale(1)' },
      ],
      { duration: 420, easing: 'ease-out' },
    );
  });

  // A thump: the level jumps to full and holds a moment; under reduced motion, a still flash.
  const thump = useEffectEvent(() => {
    live.current.kick = 1;
    live.current.hold = THUMP_HOLD;
    punch(0.8);
    if (reduced) setFlash(true);
  });

  const previous = useRef({ current, thumps });
  useEffect(() => {
    const was = previous.current;
    previous.current = { current, thumps };
    if (thumps > was.thumps) thump();
    else if (current === 'loud' && was.current !== 'loud') punch(1);
  }, [current, thumps]);

  useEffect(() => {
    if (!flash) return;
    const timer = window.setTimeout(() => setFlash(false), 600);
    return () => window.clearTimeout(timer);
  }, [flash]);

  // Draws the wave once from the live state: the level, the glow and the path.
  const paint = useEffectEvent(() => {
    const s = live.current;
    const d = wavePath(s.samples, s.shift, s.phase, s.level);
    wave.current?.setAttribute('d', d);
    glowWave.current?.setAttribute('d', d);
    dial.current?.style.setProperty('--glow', s.glow.toFixed(3));
    dial.current?.style.setProperty('--spike', spikeShare(s.level).toFixed(3));
  });

  // Reduced motion: the still drawing of the level's state, at once.
  useEffect(() => {
    if (!reduced) return;
    const s = live.current;
    const nearest = target > 0.75 ? 'loud' : target > 0.25 ? 'moving' : 'quiet';
    s.level = target;
    s.glow = glowShare(target);
    s.samples = stillSamples(nearest);
    s.shift = 0;
    s.phase = 0;
    paint();
  }, [reduced, target]);

  // The frame loop: only while shown, on screen, in a visible tab, and moving is allowed.
  useEffect(() => {
    if (!visible || reduced || !isShown) return;
    let frame = 0;
    let last = 0;
    const tick = (now: number) => {
      const s = live.current;
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
      last = now;
      s.time += dt;
      if (s.hold > 0) s.hold -= dt;
      else s.kick *= Math.exp(-dt / THUMP_FADE);
      const goal = Math.max(s.target, s.kick);
      const tau = goal > s.level ? ATTACK : RELEASE;
      s.level += (goal - s.level) * (1 - Math.exp(-dt / tau));
      // The glow trails the level, so it lasts while the burst is still crossing the dial.
      const shine = glowShare(s.level);
      s.glow += (shine - s.glow) * (1 - Math.exp(-dt / (shine > s.glow ? GLOW_IN : GLOW_OUT)));
      s.phase = (s.phase + dt * DRIFT) % (WAVELENGTH * 1000);
      s.shift += dt * SCROLL;
      while (s.shift >= STEP) {
        s.shift -= STEP;
        s.samples.shift();
        s.index += 1;
        s.samples.push(sample(s.index, s.time, s.rand));
      }
      paint();
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [visible, reduced, isShown]);

  const choose = (next: NoiseState) => {
    take();
    setOwnState(next);
    setOwnShown(true);
    setMessage(`${label}: ${NAMES[next].toLowerCase()}`);
    onStateChange?.(next);
    if (!isShown) onShownChange?.(true);
  };

  const onRadioKey = (event: ReactKeyboardEvent, index: number) => {
    const move =
      event.key === 'ArrowRight' || event.key === 'ArrowDown'
        ? 1
        : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
          ? -1
          : 0;
    if (!move && event.key !== 'Home' && event.key !== 'End') return;
    event.preventDefault();
    const next =
      event.key === 'Home'
        ? 0
        : event.key === 'End'
          ? STATES.length - 1
          : (index + move + STATES.length) % STATES.length;
    choose(STATES[next]);
    radios.current[next]?.focus();
  };

  const meter = (
    <div
      ref={dial}
      className={styles.dial}
      data-part="dial"
      data-state={current}
      data-shown={isShown}
      role="meter"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(target * 100)}
      aria-valuetext={NAMES[current]}
      aria-hidden={isShown ? undefined : true}
      style={
        {
          '--size': `${size}px`,
          '--glow': glowShare(target).toFixed(3),
          '--spike': spikeShare(target).toFixed(3),
        } as CSSProperties
      }
    >
      <svg className={styles.art} viewBox={`0 0 ${BOX} ${BOX}`} aria-hidden="true">
        <defs>
          <clipPath id={`${ids}disc`}>
            <circle cx={MID} cy={MID} r={MID} />
          </clipPath>
          <radialGradient id={`${ids}bloom`} cx="50%" cy="50%" r="50%">
            <stop offset="0" stopColor="var(--bloom)" stopOpacity="0.7" />
            <stop offset="0.55" stopColor="var(--bloom)" stopOpacity="0.28" />
            <stop offset="1" stopColor="var(--bloom)" stopOpacity="0" />
          </radialGradient>
          <filter id={`${ids}blur`} x="-20%" y="-20%" width="140%" height="140%">
            <feGaussianBlur stdDeviation="1.1" />
          </filter>
        </defs>
        <circle className={styles.disc} data-part="disc" cx={MID} cy={MID} r={MID} />
        <g clipPath={`url(#${ids}disc)`}>
          <ellipse
            className={styles.bloom}
            data-part="bloom"
            cx={MID}
            cy={MID}
            rx={MID * 0.92}
            ry={MID * 0.72}
            fill={`url(#${ids}bloom)`}
          />
          <path
            ref={glowWave}
            className={styles.glowWave}
            data-part="halo"
            d={still(current)}
            filter={`url(#${ids}blur)`}
          />
          <path ref={wave} className={styles.wave} data-part="wave" d={still(current)} />
        </g>
        <circle className={styles.rim} data-part="rim" cx={MID} cy={MID} r={MID - 0.25} />
      </svg>
    </div>
  );

  const rootProps = {
    ref: root,
    'data-color-scheme': colorScheme,
  };

  if (bare) {
    return (
      <div {...rootProps} className={`${styles.root} ${styles.bare} ${className}`}>
        {meter}
      </div>
    );
  }

  return (
    <div {...rootProps} className={`${styles.root} ${styles.stage} ${className}`} data-part="stage">
      <div className={styles.hud} data-part="hud">
        <div className={styles.slot}>{meter}</div>
        <div className={styles.controls} data-part="controls">
          <div
            className={styles.group}
            data-part="levels"
            role="radiogroup"
            aria-label={`${label} level`}
            onPointerDown={take}
          >
            {STATES.map((name, i) => (
              <button
                key={name}
                ref={(node) => {
                  radios.current[i] = node;
                }}
                type="button"
                role="radio"
                aria-checked={current === name}
                tabIndex={current === name ? 0 : -1}
                className={styles.chip}
                data-state={name}
                onClick={() => choose(name)}
                onKeyDown={(event) => onRadioKey(event, i)}
              >
                <span className={styles.pip} data-part="pip" aria-hidden="true" />
                {NAMES[name]}
              </button>
            ))}
          </div>
          <button
            type="button"
            className={styles.chip}
            data-part="sneak"
            aria-pressed={isShown}
            onClick={() => {
              take();
              setOwnShown(!isShown);
              setOwnState(current);
              setMessage(isShown ? `${label} meter hidden` : `${label} meter shown`);
              onShownChange?.(!isShown);
            }}
          >
            Sneak
          </button>
          <button
            type="button"
            className={styles.chip}
            data-part="thump"
            onClick={() => {
              take();
              setOwnState(current);
              setOwnShown(true);
              setOwnThumps((n) => n + 1);
              setMessage(`${label}: a loud thump`);
            }}
          >
            Thump
          </button>
        </div>
      </div>
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### noise-meter/noise-meter.module.css

```css
/* Noise meter (noise-meter.tsx): a dark dial with a violet wave, shown on its own on the page,
   with small neutral chips under it to drive it.

   The dial itself is the same on both schemes, as a game HUD is: a near-black disc and a violet
   trace. To read on any background it carries its own edge, as HUD elements do: a soft drop
   shadow on light, a faint light rim on dark. The chips follow the scheme. Colours are the tokens
   below, light-dark(light, dark) where the two differ: the component takes the color-scheme it
   inherits from the page, or the one its colorScheme prop sets (data-color-scheme).

   Sizes in the dial are in the drawing's units (the kit's 50 px dial); --size scales it all. The
   frame loop writes two custom properties on the dial: --glow (0–1, the violet bloom and the
   trace's halo) and --spike (0–1, how much of the noise is spikes). */

.root {
  /* The dial: disc, trace, and the bloom behind a loud trace. Fixed on both schemes. */
  --disc: #121212;
  --trace: #be5cbc;
  --bloom: #9a33a0;
  /* The dial's own edge, so it reads on a light page and on a dark one. */
  --rim: rgb(0 0 0 / 0.5);
  --drop: rgb(20 10 24 / 0.22);
  /* The chips: small neutral controls that follow the scheme. */
  --chip: #ffffff;
  --chip-hover: #f1eff3;
  --chip-on: #f6eef7;
  --chip-ink: #1c1a1f;
  --chip-mute: #6c6871;
  --chip-edge: rgb(20 16 24 / 0.12);
  --chip-on-edge: rgb(154 51 160 / 0.55);
  --focus: #9a33a0;
  /* A plain surface, painted only when colorScheme sets a scheme of its own. */
  --surface: #ffffff;
  position: relative;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --rim: light-dark(rgb(0 0 0 / 0.5), rgb(255 255 255 / 0.16));
    --drop: light-dark(rgb(20 10 24 / 0.22), rgb(0 0 0 / 0.5));
    --chip: light-dark(#ffffff, #1d1c20);
    --chip-hover: light-dark(#f1eff3, #2a282e);
    --chip-on: light-dark(#f6eef7, #2c2030);
    --chip-ink: light-dark(#1c1a1f, #f1eef4);
    --chip-mute: light-dark(#6c6871, #a39fa8);
    --chip-edge: light-dark(rgb(20 16 24 / 0.12), rgb(255 255 255 / 0.1));
    --chip-on-edge: light-dark(rgb(154 51 160 / 0.55), rgb(214 120 212 / 0.6));
    --focus: light-dark(#9a33a0, #e6a8ff);
    --surface: light-dark(#ffffff, #111113);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The stage: the lab's own plain background, the dial centred with the chips under it. */
.stage {
  container-type: inline-size;
  width: 100%;
  min-height: 320px;
  display: grid;
  place-items: center;
  padding: 28px 16px;
  box-sizing: border-box;
  font-family:
    ui-rounded,
    'SF Pro Rounded',
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
}
/* With a scheme of its own, the stage paints that scheme's plain surface, so the colours match. */
.stage[data-color-scheme] {
  border-radius: 16px;
  background: var(--surface);
}
.stage .hud {
  display: grid;
  justify-items: center;
  gap: 32px;
  max-width: 100%;
}
.stage .slot {
  display: grid;
  place-items: center;
}

/* ——— The dial ——— */
.dial {
  --size: 144px;
  position: relative;
  display: block;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  /* A soft drop under the disc: lifts it off a light page, darkens around it on a dark one. */
  box-shadow: 0 calc(var(--size) * 0.05) calc(var(--size) * 0.16) var(--drop);
  /* Shown: back to full size with an overshoot. */
  transition:
    opacity 180ms ease-out,
    scale 340ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dial[data-shown='false'] {
  opacity: 0;
  scale: 0.6;
  /* Hidden: a quick shrink and fade, no overshoot. */
  transition:
    opacity 200ms ease-in,
    scale 220ms cubic-bezier(0.55, 0, 0.8, 0.3);
}
.dial .art {
  display: block;
  position: relative;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.dial .disc {
  fill: var(--disc);
}
.dial .rim {
  fill: none;
  stroke: var(--rim);
  stroke-width: 0.5px;
}
.dial .bloom {
  opacity: calc(var(--glow, 0) * 0.75);
}
.dial .wave,
.dial .glowWave {
  fill: none;
  stroke: var(--trace);
  stroke-linejoin: round;
  stroke-linecap: round;
}
.dial .wave {
  stroke-width: calc(0.85px + var(--spike, 0) * 0.15px);
  /* Dim while quiet, full when loud. */
  opacity: calc(0.5 + var(--spike, 0) * 0.5);
}
.dial .glowWave {
  stroke-width: 1.6px;
  opacity: calc(var(--glow, 0) * 0.55);
}

/* ——— The chips ——— */
.stage .controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  max-width: 100%;
}
.stage .group {
  display: flex;
  gap: 4px;
  padding: 4px;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-edge);
}
.stage .chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 32px;
  padding: 0 14px;
  border: 0;
  border-radius: 999px;
  background: var(--chip);
  box-shadow: inset 0 0 0 1px var(--chip-edge);
  color: var(--chip-mute);
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.01em;
  cursor: pointer;
  transition:
    background-color 160ms ease-out,
    color 160ms ease-out,
    box-shadow 160ms ease-out;
}
.stage .group .chip {
  background: transparent;
  box-shadow: none;
}
.stage .chip:hover {
  color: var(--chip-ink);
}
.stage .group .chip:hover,
.stage .controls > .chip:hover {
  background: var(--chip-hover);
}
.stage .chip:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.stage .chip:active {
  scale: 0.96;
}
.stage .chip[aria-checked='true'],
.stage .chip[aria-pressed='true'] {
  color: var(--chip-ink);
}
.stage .group .chip[aria-checked='true'] {
  background: var(--chip-on);
  box-shadow: inset 0 0 0 1px var(--chip-on-edge);
}
/* A small trace swatch per level: flat, a wobble, a spike. */
.stage .pip {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--trace);
  opacity: 0.4;
  transition:
    opacity 160ms ease-out,
    box-shadow 160ms ease-out;
}
.stage .chip[aria-checked='true'] .pip {
  opacity: 1;
}
.stage .chip[data-state='loud'][aria-checked='true'] .pip {
  box-shadow: 0 0 6px 1px var(--trace);
}

.root .live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.bare {
  display: inline-block;
  line-height: 0;
}

@container (max-width: 420px) {
  .stage .chip {
    padding: 0 11px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dial,
  .dial[data-shown='false'],
  .stage .chip,
  .stage .pip {
    transition: none;
  }
  .stage .chip:active {
    scale: none;
  }
}
```

### noise-meter/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input. `visible` alone (on screen, tab shown) is also
 * returned, so a live drawing can pause offscreen whoever is driving it.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```