useSound
A pooled Audio hook for short UI sounds — preloaded voices so fast triggers overlap instead of cutting each other off, a page-wide mute and master volume, every refusal named, and every element released on unmount.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-sound.jsonPrompt
Build a React + TypeScript "useSound" hook (React only — no npm dependencies;
browser HTMLAudioElement).
Contract
- `useSound(src: string, options?: {
volume?: number // default 1, this sound's mix level, clamped to [0,1]
muted?: boolean // default false, OR'd with the page-wide mute
poolSize?: number // default 3, clamped to [1,8]
}): { play, stop, unlock, isSupported, isLoaded, isBlocked, isMuted,
isPlaying, activeVoices, volume, error }`
- `play(): PlayOutcome` — never throws, never returns a bare boolean, never
async. `PlayOutcome` is `{ ok: boolean; reason: SoundRefusal | null;
volume: number; voice: number; stolen: boolean }`, and every field is
meaningful in BOTH outcomes: on a refusal, `volume` still describes the level
that would have been used, which is exactly what a log line or a "sound is
off" hint needs. `voice` is `-1` on a refusal.
- `SoundRefusal = "no-source" | "unsupported" | "muted" | "silent" | "blocked"
| "load-failed" | "not-ready"`, evaluated in that order, first match wins.
- `stop(): void` pauses and rewinds only the voices THIS instance owns.
- `unlock(): Promise<boolean>` — call from a gesture. Plays every pooled element
at volume 0, pauses and rewinds it, restores the volume. Never rejects;
resolves to whether the whole pool is now unlocked and updates `isBlocked`.
- Module scope, not per instance: `setSoundMuted(next: boolean)`,
`getSoundMuted()`, `setSoundVolume(next: number)`, `getSoundVolume()`.
Persistence is deliberately NOT built in — read your storage on boot and call
the setters once.
- `play`, `stop` and `unlock` keep one identity for the component's whole life
(options live in a latest-ref, never in a dependency array), so an inline
`{ volume: 0.4 }` never invalidates a memoized child and never rebuilds the
pool.
Behavior
- The pool is the whole point. One element can only be in one place: replaying
it means seeking back to 0, which cuts off the sound still ringing — that is
why hand-rolled click sounds stutter under a fast hand. Build `poolSize`
elements with `preload = "auto"` in an effect keyed on `[src, poolSize]`.
Track readiness with `loadeddata`, not `canplay`: it fires at exactly the
readyState the allocator tests, so `isLoaded` never disagrees with `play()`.
- Voice allocation, exactly: walk the pool once from a cursor; the first voice
that is READY (`readyState >= 2`, HAVE_CURRENT_DATA) and FREE (`paused ||
ended`) wins, and the cursor moves one past it so consecutive triggers spread
out instead of fighting over slot 0. If every ready voice is busy, the first
ready one from the cursor is the least recently started, so stealing it
restarts the OLDEST sound — the tail nobody is listening to any more —
and the outcome reports `stolen: true`. The cursor ref is read AND written
synchronously inside `play()`, so two triggers in the same tick can never be
handed the same voice. No ready voice at all → `load-failed` if any element
reported a media error, otherwise `not-ready`.
- `not-ready` is a refusal, not a wait. UI sound is a timing promise: a click
sound that arrives 400ms after the click is worse than no click sound. The
pool preloads at mount precisely so this branch is rare.
- Volume is a mixer, not two competing settings: the level applied is
`clamp01(options.volume) * clamp01(masterVolume)`. A master change is applied
to the whole pool in an effect, so it reaches the sound already in the air,
not only the next one. `0` is refused as `silent` — a fader at zero and a
switch flipped off are different facts and must not read the same in a log.
- `setSoundMuted(true)` must stop what is currently sounding. A mute switch that
lets the noise already in the air finish is the failure people complain about.
- Autoplay policy: `element.play()` returns a promise that rejects with
`NotAllowedError` on a page the user has never interacted with, and the
rejection lands where no consumer `try` can catch it. Catch it in the hook,
set `isBlocked`, and refuse subsequent calls with `blocked` — a standing
page-level verdict outranks anything about this source's health, and it is the
one refusal with a remedy: `unlock()` from any gesture. A later successful
start clears the flag. Also treat `NotSupportedError` as a load failure, and
IGNORE `AbortError` entirely: it means a newer trigger took this voice or
`stop()` paused it, which is how a pool recycles.
- iOS Safari unlocks elements INDIVIDUALLY: an element that has never been
started inside a gesture will not play later, no matter how many other
elements were. That is why `unlock()` walks the whole pool, one element at a
time rather than firing them together.
- A conditional sound is spelled `useSound(enabled ? src : "")` — hooks cannot be
called conditionally, and an empty `src` builds no pool, issues no request and
refuses with `no-source`.
- Cleanup, on unmount AND on every `[src, poolSize]` change: detach every
listener FIRST (pausing fires `pause`, dropping the source can fire `emptied`
/ `error`, and neither should reach a component on its way out), then per
element `pause()`, `removeAttribute("src")`, `load()` — that last pair is what
actually releases the decoded buffer and cancels an in-flight request. State
belonging to the old source (`isLoaded`, `activeVoices`, `error`, `isBlocked`)
is reset as a render-phase adjustment against a tracked `{src, poolSize}`, not
with a `setState` inside the effect, which would paint one frame claiming the
old source's readiness for the new one.
- SSR/hydration: capability detection goes through `useSyncExternalStore` with a
server snapshot (`isSupported: false`), and the page-wide store is distributed
the same way. Nothing touches `window` during render, so there is no hydration
mismatch and no `typeof window` guard at module top level.
- Seeking throws `InvalidStateError` before metadata exists; wrap the rewind and
move on, since an element that never started plays from 0 anyway.
Rendering & styling
- The hook renders nothing and owns no DOM. Two consumer rules keep it honest:
every sound must accompany a visible change (a row leaving, a badge flipping,
a toast) — sound is a channel many users do not have, and a noise with no
on-screen cause is indistinguishable from a bug in another tab; and the
feature must work fully with sound refused, because muted, blocked and
`prefers-reduced-motion` users are the normal path, not the edge.
- Keyboard: the trigger is a real `<button>` (or the form's submit), so Enter and
Space are the same code path as the pointer — there is never a sound that only
a mouse can reach. A slider that ticks per step must tick on Arrow keys too.
- ARIA: render the outcome into an element that is mounted from the first paint
with `role="status"` and `aria-live="polite"`, so a refusal is announced rather
than only repainted; `aria-live="assertive"` would interrupt a screen reader
on every click.
- Controls that can become unavailable (a `stop()` button with nothing playing,
an "Enable sound" button once unlocked) use `aria-disabled` plus an early
return in the handler, never the native `disabled` attribute — the browser
blurs a disabled control and drops focus to `<body>` under the user's hands.
- Feedback UI uses semantic tokens only: `bg-primary` / `text-primary` for an
active voice indicator, `bg-muted` / `text-muted-foreground` for idle and for
policy refusals, `text-destructive` for `load-failed` / `blocked` /
`unsupported`, `border` and `bg-card` for the surrounding surface, and
`accent-primary` for a native range input used as the volume fader.
- Any pulsing "now playing" indicator gets `motion-reduce:animate-none`; the
state it reports must stay readable with the animation off.
- Good magnitudes: 60–120ms is a tick, 200–300ms is a confirmation, over 500ms
is a jingle and does not belong on a button. Keep the file small enough to
preload without thinking (a few KB), and mix UI sounds well below full scale —
the master fader belongs to the user, not to you.
Customization levers
- `poolSize` is the character dial: 1 gives the classic cut-off (right for a
typing tick, where overlap is just mud), 3 covers the fastest a human clicks,
6–8 suits a list where many rows can confirm at once.
- Swap the refusal union for your own policy: it is the extension point — add
`"quiet-hours"` or `"in-a-call"` as extra guards before the platform call and
they flow through `PlayOutcome.reason` unchanged.
- Wrap `setSoundMuted` / `setSoundVolume` in your own persistence (localStorage,
or a server-side user preference) and call them once at boot; keep the module
store as the single runtime source of truth so no component takes a `muted`
prop.
- Sound names belong to the app, not the hook: export an `SFX` map next to your
design tokens (`SFX.send`, `SFX.error`) so call sites read like the rest of
the system, and give each surface its own `useSound(SFX.x)` rather than one
hook switching sources — each keeps its own warm pool.
- Need one preload budget for a whole sprite sheet instead of per sound? Keep
the API and swap the pool's contents for a shared `AudioContext` +
`AudioBuffer` decode: the refusal ladder, the mixer and the cleanup contract
stay exactly as they are.
- For a haptic echo of the same gesture, call `hooks/use-vibrate` next to
`play()`; for something the user is not watching, use
`hooks/use-notification` instead — a sound they cannot see the cause of is
noise.Concepts
- A voice pool, and stealing — one
Audioelement can only be in one place, so replaying it means seeking back to 0 and cutting off the sound still ringing; that stutter under a fast hand is the defining bug of hand-rolled click sounds. KeepingpoolSizepreloaded copies and handing out the first ready and free one lets two triggers overlap the way they do in every native UI. When they are all busy the oldest is restarted rather than a random one — its tail is the one nobody is still listening to — and the outcome admits it withstolen: true. - A sound is the echo of a gesture — every
play()should sit on something the user just did. That is not taste, it is the platform: browsers refuse to start audio on a page nobody has interacted with, so "ding when the background job finishes" mostly never fires, and when it does it is a noise in a shared room with no visible cause. Announcing something the user is not watching is a desktop notification's job. - Blocked is a flag with a remedy — a refused
play()rejects asynchronously withNotAllowedErrorin a place no consumertrywill ever catch, which is why hand-rolled sound "just does not work sometimes". Named and surfaced asisBlocked, it becomes an "Enable sound" button that callsunlock(); and because iOS Safari unlocks elements individually, that unlock has to walk the whole pool, not just the first voice. - Named refusals —
play()failing has seven completely different causes and only some are yours to fix: an emptysrcis your bug,mutedis the user's wish,not-readyis a preload that has not landed,load-failedis a wrong path or codec,blockedis policy. Once they are told apart, logs and UI can say something useful instead of shrugging. - The mixer is page-level — "sound effects: off" is an app-level preference, not a property of some button, so mute and master volume live in module scope and are distributed with
useSyncExternalStore; the level applied is fader × master, and muting stops what is currently sounding rather than letting it finish. Persistence stays outside the hook: localStorage or a server-side profile is not its guess to make. - Release, not just pause — on unmount and on every source change, listeners come off first (so a
pauseorerrorfired by the teardown reaches nobody), then each element is paused, itssrcattribute removed andload()re-run. Pausing alone leaves a decoded buffer and possibly a live request per voice; a page with six sounds then holds six downloads it will never use again.
useEyeDropper
The screen eyedropper as a hook: open() resolves to a typed outcome — picked, canceled, unsupported or failed — instead of throwing, with hydration-safe support detection, a one-pick-at-a-time guard, and an AbortSignal that unmount fires for you.
useWorker
Run a pure function off the main thread: the function is stringified into a Blob worker, every call is a promise matched back by id so out-of-order completions land on the right caller, transferables move in both directions, task errors reject without replacing the worker, and everything is terminated on unmount.