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.

enabled (live)
volume (live)0.25
Customize
Initial state
0.25

Installation

npx shadcn@latest add https://craftbits.dev/r/use-sound.json

No 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";
NameVoicingUse for
tapFiltered noise burst, 8 msStylus-on-table feedback for a discrete click
popSine sweep 400 → 200 Hz, 50 msA soft blip — chip placed, item added
selectHighpass noise click + 880 Hz ping, 60 msExplicit menu selection
successTwo-note sine arpeggio C5 → E5, 200 msSubmit accepted, save complete
error110 Hz square + lowpass + soft waveshaper, 200 msInvalid input, blocked action

Parameters

FieldTypeDefaultDescription
options.enabledbooleantrueInitial enabled state. Overridden by persisted value if storageKey is set.
options.volumenumber (0–1)0.25Master gain. Per-call play(name, { volume }) multiplies into this.
options.storageKeystring | null"use-sound"localStorage key for persisting enabled + volume. Pass null to opt out.

Return value

FieldTypeDescription
play(name: SoundName, opts?: { volume?: number }) => voidPlay a named sound. No-op when disabled, on reduced-motion, or in SSR.
enabledbooleanWhether the hook will produce audio.
setEnabled(next: boolean) => voidToggle / set enabled. Persists if storageKey is set.
volumenumberMaster volume (0–1).
setVolume(next: number) => voidSet master volume. Persists if storageKey is set.

Behaviour

  1. One shared AudioContext. A module-scope context is lazily constructed on the first play call and reused across every hook instance. Mounting the hook in fifty components does not spawn fifty graphs.
  2. prefers-reduced-motion short-circuits play. Audio cues fall under the same vestibular-comfort umbrella as motion. When the user has reduced motion enabled, play(name) returns immediately.
  3. Persistence is opt-out. Enabled + volume sync to localStorage under storageKey (default "use-sound"). Pass storageKey: null to keep state purely in-memory.
  4. SSR-safe. Initial render matches the server (ignores localStorage); the persisted value hydrates in an effect. On servers and browsers without window.AudioContext, play is a no-op and the hook still returns a stable shape.
  5. Cleanup. Every voice's nodes are disconnect()ed via a setTimeout scheduled 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-settingenabled + setEnabled are first-class. Wire them to a settings switch.
  • a11y-reduced-motion-checkplay is a no-op when prefers-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 is 0.25 (≤0.3, per the wiki).
  • context-reuse-single — one shared AudioContext across 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-themed AudioBuffers (page-turn, knife-tap, ceramic-clink, …); this extraction narrows to five general UI sounds, synthesizes on the fly, drops the persistent SoundCache, and adds the master-volume + persistence surface.