Skip to main content

Overview

The useSound hook is the primary way to play sounds in React applications. It provides fine-grained control over playback with options for volume, speed, interruption, and lifecycle callbacks.

Installation

After adding a sound with the CLI, import and use it:
import { useSound } from "@/hooks/use-sound";
import clickSound from "@/sounds/click-elegant";

export function Button() {
  const [play] = useSound(clickSound);
  
  return <button onClick={play}>Click me</button>;
}

Basic Usage

1

Import the Hook

import { useSound } from "@/hooks/use-sound";
2

Import Your Sound

import successSound from "@/sounds/success-chime";
3

Call the Hook

const [play, { stop, pause, isPlaying, duration, sound }] = useSound(successSound);
4

Play the Sound

<button onClick={play}>Play Success Sound</button>

API Reference

Hook Signature

function useSound(
  sound: SoundAsset,
  options?: UseSoundOptions
): UseSoundReturn

SoundAsset Type

Each sound you import is a SoundAsset:
interface SoundAsset {
  name: string;        // Unique identifier
  dataUri: string;     // Base64-encoded audio data
  duration: number;    // Duration in seconds
  format: "mp3" | "wav" | "ogg";
  license: "CC0" | "OGA-BY" | "MIT";
  author: string;      // Original creator
}

Return Value

The hook returns a tuple:
type UseSoundReturn = readonly [
  play: PlayFunction,
  controls: SoundControls
]
Function to start playback. Accepts optional overrides:
type PlayFunction = (overrides?: {
  volume?: number;
  playbackRate?: number;
}) => void;

Options

Volume

Control playback volume from 0 (silent) to 1 (full volume):
const [play] = useSound(clickSound, {
  volume: 0.5 // 50% volume
});
volume
number
default:"1"
Volume level from 0 to 1. Changes are reactive - updating this value will adjust volume during playback.
const [play] = useSound(sound, { volume: 0.3 });

Playback Rate

Adjust playback speed (pitch and tempo):
const [play] = useSound(sound, {
  playbackRate: 1.5 // 1.5x speed
});
playbackRate
number
default:"1"
Speed multiplier. Values < 1 slow down, > 1 speed up. Typical range: 0.5 to 2.0.
const [play] = useSound(sound, { playbackRate: 0.5 });

Interrupt

Control whether new playback stops current playback:
const [play] = useSound(sound, {
  interrupt: true // Stop current playback before starting new
});
interrupt
boolean
default:"false"
If true, calling play() stops any currently playing instance first. If false, sounds can overlap.
Use interrupt: true for:
  • UI sounds that shouldn’t overlap (button clicks)
  • Status sounds (notifications, alerts)
  • Voice clips or announcements
Use interrupt: false for:
  • Ambient effects that can layer
  • Musical notes or chords
  • Multiple simultaneous impacts
const [play] = useSound(laserSound);

// Rapid clicks create overlapping sounds
<button onClick={play}>Pew Pew Pew</button>

Sound Enabled

Globally enable/disable sound playback:
const [soundEnabled, setSoundEnabled] = useState(true);
const [play] = useSound(sound, { soundEnabled });
soundEnabled
boolean
default:"true"
Master switch for playback. When false, calling play() does nothing. Perfect for user preferences.
This option is checked every time play() is called. If soundEnabled is false, the function returns immediately without creating audio nodes.
function useUserSoundPreference() {
  const [enabled, setEnabled] = useState(() => {
    return localStorage.getItem("soundEnabled") !== "false";
  });
  
  const toggle = () => {
    const newValue = !enabled;
    setEnabled(newValue);
    localStorage.setItem("soundEnabled", String(newValue));
  };
  
  return [enabled, toggle] as const;
}

function MyComponent() {
  const [soundEnabled] = useUserSoundPreference();
  const [play] = useSound(clickSound, { soundEnabled });
  
  return <button onClick={play}>Click</button>;
}

Lifecycle Callbacks

React to playback events:
const [play] = useSound(sound, {
  onPlay: () => console.log("Started"),
  onEnd: () => console.log("Finished naturally"),
  onPause: () => console.log("Paused"),
  onStop: () => console.log("Stopped")
});
onPlay
() => void
Called when play() successfully starts playback
onEnd
() => void
Called when sound finishes playing naturally (not stopped manually)
onPause
() => void
Called when pause() is called
onStop
() => void
Called when stop() is called or when interrupted
const [play] = useSound(sound, {
  onPlay: () => {
    analytics.track("Sound Played", { sound: sound.name });
  },
  onEnd: () => {
    analytics.track("Sound Completed", { sound: sound.name });
  }
});

Advanced Patterns

Play with Overrides

Override volume or playback rate for specific calls:
const [play] = useSound(sound, {
  volume: 0.7,
  playbackRate: 1.0
});

// Use default settings
play();

// Override for this playback only
play({ volume: 0.3, playbackRate: 0.5 });

// Original settings remain for next play()
play();
Overrides don’t change the hook’s default options - they only apply to that specific play() call.

Conditional Playback

function NotificationButton() {
  const [play] = useSound(notificationSound);
  const [hasPermission, setHasPermission] = useState(false);
  
  const handleClick = () => {
    if (hasPermission) {
      play();
    }
    // Handle notification logic
  };
  
  return <button onClick={handleClick}>Notify</button>;
}

Multiple Sounds

function GameButton() {
  const [playHover] = useSound(hoverSound, { volume: 0.3 });
  const [playClick] = useSound(clickSound, { volume: 0.5 });
  const [playError] = useSound(errorSound, { volume: 0.7 });
  
  const handleClick = async () => {
    try {
      playClick();
      await submitForm();
    } catch (error) {
      playError();
    }
  };
  
  return (
    <button 
      onMouseEnter={playHover}
      onClick={handleClick}
    >
      Submit
    </button>
  );
}

Progress Tracking

function SoundPlayer() {
  const [play, { isPlaying, duration, stop }] = useSound(sound);
  const [elapsed, setElapsed] = useState(0);
  
  useEffect(() => {
    if (!isPlaying) return;
    
    const interval = setInterval(() => {
      setElapsed(prev => {
        const next = prev + 0.1;
        if (duration && next >= duration) {
          clearInterval(interval);
          return duration;
        }
        return next;
      });
    }, 100);
    
    return () => clearInterval(interval);
  }, [isPlaying, duration]);
  
  const progress = duration ? (elapsed / duration) * 100 : 0;
  
  return (
    <div>
      <button onClick={play} disabled={isPlaying}>Play</button>
      <button onClick={stop} disabled={!isPlaying}>Stop</button>
      <div className="progress-bar">
        <div style={{ width: `${progress}%` }} />
      </div>
      <span>{elapsed.toFixed(1)}s / {duration?.toFixed(1)}s</span>
    </div>
  );
}
The Web Audio API doesn’t expose playback position directly. For accurate progress, implement timing logic as shown above.

Reactive Volume

Volume changes apply immediately during playback:
function VolumeControl() {
  const [volume, setVolume] = useState(1);
  const [play, { isPlaying }] = useSound(sound, { volume });
  
  return (
    <div>
      <button onClick={play}>Play</button>
      <input
        type="range"
        min="0"
        max="1"
        step="0.01"
        value={volume}
        onChange={(e) => setVolume(Number(e.target.value))}
      />
      <span>{Math.round(volume * 100)}%</span>
    </div>
  );
}

Implementation Details

Audio Context

The hook uses the Web Audio API via a shared audio context:
import { getAudioContext } from "@/lib/sound-engine";

const ctx = getAudioContext();
// Returns singleton AudioContext instance

Buffer Caching

Sounds are decoded once and cached:
// First render: decode audio data
const buffer = await decodeAudioData(sound.dataUri);
bufferRef.current = buffer;

// Subsequent plays: reuse buffer
source.buffer = bufferRef.current;
Caching happens at both the engine level (all sounds) and component level (per hook instance).

Audio Graph

Each playback creates this audio graph:
AudioBufferSourceNode → GainNode → AudioContext.destination
       (buffer)          (volume)        (speakers)

Cleanup

The hook automatically cleans up on unmount:
useEffect(() => {
  return () => {
    if (sourceRef.current) {
      try {
        sourceRef.current.stop();
      } catch {
        // Already stopped
      }
    }
  };
}, []);

Best Practices

Don’t call play() during render. Only call in event handlers or effects:
// ❌ Bad
const [play] = useSound(sound);
play(); // Called during render!

// ✅ Good
const [play] = useSound(sound);
useEffect(() => play(), []); // Called after mount
Use interrupt for UI sounds: Set interrupt: true for button clicks and UI feedback to prevent overlapping.
Respect user preferences: Always provide a way to disable sounds and respect prefers-reduced-motion.
Volume ranges: Keep UI sounds between 0.3-0.7 volume. Full volume (1.0) can be jarring.

Troubleshooting

  1. Check that soundEnabled is true
  2. Verify the sound imported correctly
  3. Ensure play() is called in an event handler (not during render)
  4. Check browser console for Web Audio API errors
  5. Verify AudioContext isn’t blocked by browser autoplay policy
Set interrupt: true to stop previous playback before starting new playback.
Volume is reactive and updates during playback. Make sure you’re passing a state variable, not a constant.
The hook cleans up automatically. If you see warnings, ensure you’re not calling play() after unmount.

Next Steps

Build docs developers (and LLMs) love