Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Why a `play()` call never reached the speakers. Listed **in evaluation order**, first match
 * wins:
 *
 * - `no-source` — `src` is empty. A caller bug, not an environment one, so it comes first: no
 *   browser can play nothing. It is also the sanctioned way to spell a conditional sound
 *   (`useSound(enabled ? clickSfx : "")`), because a hook cannot be called conditionally.
 * - `unsupported` — this environment has no `Audio` constructor: SSR, and the first hydration
 *   frame.
 * - `muted` — the page-wide mute or this instance's `muted` option is on. The user's stated

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-sound.json

Prompt

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 Audio element 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. Keeping poolSize preloaded 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 with stolen: 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 with NotAllowedError in a place no consumer try will ever catch, which is why hand-rolled sound "just does not work sometimes". Named and surfaced as isBlocked, it becomes an "Enable sound" button that calls unlock(); and because iOS Safari unlocks elements individually, that unlock has to walk the whole pool, not just the first voice.
  • Named refusalsplay() failing has seven completely different causes and only some are yours to fix: an empty src is your bug, muted is the user's wish, not-ready is a preload that has not landed, load-failed is a wrong path or codec, blocked is 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 pause or error fired by the teardown reaches nobody), then each element is paused, its src attribute removed and load() 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.

On This Page