useSound
A React hook for procedurally synthesized UI sound effects. Five named voicings (tap, pop, select, success, error) are generated at play time with the Web Audio API — no asset files, no preloading. A single AudioContext is shared across every hook instance, the master volume defaults to a polite 0.25, and play() becomes a no-op under prefers-reduced-motion.
Use it for meaningful confirmations — submit accepted, copy succeeded, action blocked. Not for hover or typing.
Installation
npx shadcn@latest add https://craftbits.dev/r/use-sound.jsonNo external dependencies. Web Audio is built into the browser.
Usage
"use client";
import { useSound } from "@craft-bits/core";
export function SaveButton({ onSave }: { onSave: () => void }) {
const { play } = useSound();
return (
<button
onClick={() => {
onSave();
play("success");
}}
>
Save
</button>
);
}Wire enabled state to a settings switch so users can opt out:
const { enabled, setEnabled } = useSound();
<label>
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Sound effects
</label>API
Sound names
type SoundName = "tap" | "pop" | "select" | "success" | "error";| Name | Voicing | Use for |
|---|---|---|
tap | Filtered noise burst, 8 ms | Stylus-on-table feedback for a discrete click |
pop | Sine sweep 400 → 200 Hz, 50 ms | A soft blip — chip placed, item added |
select | Highpass noise click + 880 Hz ping, 60 ms | Explicit menu selection |
success | Two-note sine arpeggio C5 → E5, 200 ms | Submit accepted, save complete |
error | 110 Hz square + lowpass + soft waveshaper, 200 ms | Invalid input, blocked action |
Parameters
| Field | Type | Default | Description |
|---|---|---|---|
options.enabled | boolean | true | Initial enabled state. Overridden by persisted value if storageKey is set. |
options.volume | number (0–1) | 0.25 | Master gain. Per-call play(name, { volume }) multiplies into this. |
options.storageKey | string | null | "use-sound" | localStorage key for persisting enabled + volume. Pass null to opt out. |
Return value
| Field | Type | Description |
|---|---|---|
play | (name: SoundName, opts?: { volume?: number }) => void | Play a named sound. No-op when disabled, on reduced-motion, or in SSR. |
enabled | boolean | Whether the hook will produce audio. |
setEnabled | (next: boolean) => void | Toggle / set enabled. Persists if storageKey is set. |
volume | number | Master volume (0–1). |
setVolume | (next: number) => void | Set master volume. Persists if storageKey is set. |
Behaviour
- One shared AudioContext. A module-scope context is lazily constructed on the first
playcall and reused across every hook instance. Mounting the hook in fifty components does not spawn fifty graphs. prefers-reduced-motionshort-circuitsplay. Audio cues fall under the same vestibular-comfort umbrella as motion. When the user has reduced motion enabled,play(name)returns immediately.- Persistence is opt-out. Enabled + volume sync to
localStorageunderstorageKey(default"use-sound"). PassstorageKey: nullto keep state purely in-memory. - SSR-safe. Initial render matches the server (ignores
localStorage); the persisted value hydrates in an effect. On servers and browsers withoutwindow.AudioContext,playis a no-op and the hook still returns a stable shape. - Cleanup. Every voice's nodes are
disconnect()ed via asetTimeoutscheduled past the envelope tail — the audio graph stays small under rapid playback.
Accessibility
The hook follows the wiki's audio-feedback rules. Audio is never the only signal — pair every play(name) with a visible state change.
a11y-toggle-setting—enabled+setEnabledare first-class. Wire them to a settings switch.a11y-reduced-motion-check—playis a no-op whenprefers-reduced-motion: reduce.appropriate-confirmations-only— reserve sound for meaningful confirmations (submit, complete, error). Never on hover or typing.impl-default-subtle— default master volume is0.25(≤0.3, per the wiki).context-reuse-single— one sharedAudioContextacross every hook instance.
Sound design notes
Every voicing uses exponential gain ramps for attack (4 – 6 ms) and decay (30 – 80 ms in most cases). Linear ramps sound mechanical; exponential ramps match how the ear maps amplitude (wiki envelope-exponential-decay).
Percussive voices (tap, select) start from noise sources rather than oscillators — your ear maps short noise to "click" or "thump" much more readily than a short sine wave (design-noise-for-percussion).
The two-tone success arpeggio uses a major third (C5 → E5) for a positive, non-saccharine "accepted" feel. The error voice uses a low square wave with a soft waveshaper for the "uh-uh" quality without being shrill.
Credits
- Extracted from:
terminal-dreams(src/hooks/use-sound.ts+src/lib/sound-manager.ts). Source pre-rendered nine cookbook-themedAudioBuffers (page-turn, knife-tap, ceramic-clink, …); this extraction narrows to five general UI sounds, synthesizes on the fly, drops the persistentSoundCache, and adds the master-volume + persistence surface.