Noise meter
NewA stealth HUD dial whose violet trace ripples calmly while you sneak and spikes and glows when you are loud.
noise-meter/noise-meter.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
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.
You can add this component with the shadcn CLI or by hand:
Into components/noise-meter of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/noise-meter.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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
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} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
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.
- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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' | '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 | 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 | 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 | 144 | The dial’s width in px; the kit draws it at 50. Everything inside scales with it. |
bare | boolean | false | Just the dial: no controls, to place in a HUD of your own. |
label | string | 'Noise' | The meter’s accessible name, also used in its announcements. |
autoplay | boolean | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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/* 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;// 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);- 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.