Skip to content

Sound Design

A UX principle for coding agents. Also covers audio, sonification, feedback sound, click sound, ui sound effects, audio sprite, and 12 more.

Show all 18 aliases

audio, sonification, feedback sound, click sound, ui sound effects, audio sprite, sprite map, web audio api, AudioBufferSourceNode, HTMLAudioElement, autoplay policy, NotAllowedError, playbackRate, pitch shifting, mute button, sounds do not overlap, sound cuts itself off, audio will not play until I click

Core Philosophy

  • Sound is a second channel running alongside the visible one. It can confirm, reinforce and add weight, and it may never be the only place information lives
  • Interface sound is quiet, short and always a direct response to a user action. Nothing loops, nothing plays ambiently, nothing starts on page load
  • Sound must match the weight of the action. A button click wants subtlety; a payment confirmation can have presence
  • The browser will not let you play the first sound until the user has interacted. Design around that gate rather than fighting it
  • Every sound-producing interface owes the user a way to silence it from inside the interface, not from their operating system
  • Motion timing and easing live in docs/ui/. This doc owns audio only

When sound earns its place

  • Confirmations of actions with real consequences: payments, uploads, submissions, destructive operations
  • Errors and warnings that must not be overlooked
  • Completion of something the user stopped watching: a long upload, a background job
  • Tactile feedback on controls that repeat quickly: slider detents, number steppers, toggles
  • Not every interaction needs audio. A sound that communicates nothing is noise, and noise is what makes people mute your site permanently
  • Start with one interaction that feels flat, ship it with a mute control, and expand only if it holds up

The autoplay gate

Browsers block audio until the user has interacted with the page. These are the rules that actually decide whether your first sound plays.

  • The first sound must be triggered from a genuine user-activation event. The event must have isTrusted === true and be one of keydown (Esc and browser-reserved keys do not count), mousedown, pointerdown with pointerType of "mouse", pointerup when pointerType is not mouse, or touchend
  • scroll and pointermove are not activation events. A first sound wired to hover or scroll will not play
  • Autoplay is gated on sticky activation, which persists for the rest of the session once the user has interacted. That is why hover and scroll sounds start working after one successful playback
  • Do the unlock inside the activation handler, not from a promise callback or a timer. The specced gate is sticky, but engine behaviour is stricter than the baseline in practice, and MDN's own guidance shows the context being created or resumed directly inside the click handler. Deferring it is the common cause of a first sound that silently never fires
const context = new AudioContext();

const UNLOCK_EVENTS = ["keydown", "mousedown", "pointerdown", "pointerup", "touchend"];

function isActivation(event) {
  if (!event.isTrusted) return false;
  if (event.type === "keydown") return event.key !== "Escape";
  if (event.type === "pointerdown") return event.pointerType === "mouse";
  if (event.type === "pointerup") return event.pointerType !== "mouse";
  return true;
}

function unlock(event) {
  if (!isActivation(event)) return;

  // resume() is called inside the handler. Only the teardown waits on the promise.
  context.resume().then(() => {
    if (context.state !== "running") return;
    for (const type of UNLOCK_EVENTS) {
      document.removeEventListener(type, unlock);
    }
  });
}

for (const type of UNLOCK_EVENTS) {
  document.addEventListener(type, unlock);
}
  • Listen for the whole set and filter, rather than picking one event name. A mouse unlocks on pointerdown, a touch or a pen unlocks on pointerup or touchend, and a keyboard unlocks on keydown. Listening on bare pointerdown is the common version of this bug: it works on every desktop you test on and never unlocks on touch, because a touch pointerdown is not an activation event
  • Tear the listeners down only once the context has actually reached running, and not on the first event that happens to arrive. Removing them inside a non-qualifying handler throws away the qualifying event that was about to follow
  • resume() returns a promise that resolves when the context has resumed, so the state check belongs in that callback. The call itself still happens synchronously inside the handler, which is what the gate cares about
  • An AudioContext created before any interaction starts suspended. Create it early so decoding can happen up front, then resume() it on the first activation
  • navigator.userActivation.hasBeenActive reports sticky activation and navigator.userActivation.isActive reports transient activation. Where it is available, navigator.getAutoplayPolicy("audiocontext") returns "allowed", "allowed-muted" or "disallowed" so you can decide before trying
  • With an <audio> element, play() returns a promise that rejects with NotAllowedError when autoplay is blocked. Never swallow it in an empty catch. Either surface a control the user can press, or record it, so a site-wide failure is visible
  • Browsers also allowlist sites automatically when they judge that the user engages with media there often. Your machine is allowlisted after a day of development. Test the first-run path in a fresh profile or the gate will look like it does not exist
  • A cross-origin iframe is treated more strictly. The embedding page must grant permission with allow="autoplay" on the <iframe>, and a frame cannot grant it to itself. Permissions-Policy: autoplay=() turns it off for the document and every nested frame. If your widget is embedded by third parties, assume it may never be granted audio

Playing sounds that can overlap

A single HTMLAudioElement has one playhead, and that makes it the wrong primitive for interface feedback.

  • Setting currentTime = 0 before play() restarts that one playhead. A second trigger cuts the first sound off mid-decay instead of layering over it, so rapid clicks sound clipped and machine-gunned
  • Without the reset it fails the other way: calling play() on an element that is already playing does nothing audible, so fast repeats drop
  • Either way, two takes of the same sample can never sound at once, which is exactly what sample variation below depends on

An AudioBufferSourceNode can only be played once. That constraint is the fix: create a new node per trigger and reuse the decoded buffer. The nodes are cheap, and you can fire and forget them, because a finished node is garbage-collected without you holding a reference.

const buffers = new Map();

async function load(name, url) {
  try {
    const response = await fetch(url);
    // fetch rejects only on a network failure, so a 404 arrives as a
    // resolved response and has to be checked by hand.
    if (!response.ok) throw new Error(`${response.status} loading ${url}`);

    const bytes = await response.arrayBuffer();
    buffers.set(name, await context.decodeAudioData(bytes));
    return true;
  } catch (error) {
    // One unusable file costs one sound. It must not reject into the caller
    // and take the rest of the kit with it.
    reportError(error);
    return false;
  }
}

function play(name, { offset = 0, duration, rate = 1, volume = 0.25 } = {}) {
  const buffer = buffers.get(name);
  if (!buffer || !soundEnabled()) return;

  const source = new AudioBufferSourceNode(context, { buffer, playbackRate: rate });
  const gain = new GainNode(context, { gain: volume });

  source.connect(gain);
  gain.connect(context.destination);
  source.start(context.currentTime, offset, duration);
}
  • A load can fail three ways: fetch rejects on a network error, a 404 comes back as a resolved response with ok false, and decodeAudioData rejects on a file the browser cannot decode. All three leave buffers without that entry, and play already returns early on a missing buffer, so a failed asset degrades to silence
  • Report the failure rather than swallowing it. reportError hands the error to the same handlers as an uncaught exception, so whatever already collects errors sees a kit that did not load. An empty catch here is the same mistake as an empty catch on a blocked play()
  • Returning the outcome lets a caller load a whole kit with Promise.all and still know what is missing, instead of the first bad asset rejecting the batch
  • soundEnabled() is the module-level preference read defined in the off switch section below. play() calls it on every trigger, so a mute takes effect on the very next sound and no React state sits in the path
  • offset and duration on start() are seconds, and they index into the buffer's own time, so a sprite segment stays correct whatever the playback rate
  • Decode once at load and keep the AudioBuffer. Decoding per trigger reintroduces the latency you removed
  • The minimal fix without Web Audio is a fresh element per trigger, new Audio(url), which layers because each element has its own playhead. It costs an element per sound and leans on the HTTP cache for the file, so it is a stopgap rather than the answer
  • A library built for overlapping playback is a reasonable alternative and gives you sprites, per-instance volume and rate without hand-writing the graph. Its play() returns an id per instance so several instances of the same sound can run and be controlled independently

Sprite maps

One file, one request, one decode, many named segments.

  • A sprite map is a set of named entries, each giving a start offset and a duration in milliseconds
const TICK_SPRITE = {
  a: [52, 30],
  b: [88, 30],
  c: [124, 30],
  d: [163, 30],
  e: [198, 30],
};
  • Web Audio takes seconds, so divide by 1000 at the call site: const [start, length] = TICK_SPRITE.a; then play("ticks", { offset: start / 1000, duration: length / 1000 })
  • Pad the duration well past the audible end of the sound. Cutting a segment before it has fully decayed truncates the waveform mid-cycle and produces an audible click, which is worse than the sound you were trying to play
  • The padding is generous by design. The ticks above are audibly only a few milliseconds each, and each window is 30ms. A few times the audible length is the right order
  • Leave silence between sounds in the source file so the padding never reaches into the next segment
  • Read the offsets off a waveform in an audio editor rather than guessing. Select a sound, note the start and end, and the duration is the difference

Sample variation

A physical button never sounds identical twice. Mechanical noise, finger position and force all shift it slightly. A single audio file replayed on every press is the strongest tell that a sound is synthetic, and it gets worse the faster the interaction repeats.

  • Source 3 to 5 takes of the same sound. More than that is rarely distinguishable
  • Pick one at random per trigger, and avoid replaying the take that played last
  • The effect is most noticeable on rapid repeats: dragging a slider with detents, stepping a number input, typing feedback
  • Package the takes as one sprite so there is one request and one decode
  • This only works if playback layers. On a single reused element the takes cut each other off, which is the bug the section above fixes

Cheaper alternatives when extra takes are not available:

  • Nudge the playback rate per trigger, roughly plus or minus 3 percent, so rate lands somewhere between 0.97 and 1.03. That breaks the identical repeat with one file. Percent is the right unit because playbackRate is a multiplier on the sampling rate where 1 is unchanged, and its sibling detune expresses the same shift in cents instead, covered in the pitch section below
  • Vary the gain slightly per trigger, within a few percent
  • Both are subtle enough to pass unnoticed while removing the machine-gun quality of an exact repeat

Press and release

  • A press and its release are two different sounds. Playing the press on pointerdown and the release on pointerup makes the control feel mechanical rather than triggered
  • Keep the release quieter than the press
  • Mirror the pair on the keyboard, on keydown and keyup for Enter and Space, or keyboard users get a silent control while pointer users get a tactile one
  • Play the release on pointercancel and on blur as well. A drag that leaves the control, or focus lost mid-press, otherwise leaves the press sound hanging with no resolution
  • Guard against key repeat. Holding Enter fires keydown continuously, so ignore events where event.repeat is true

Pitch through playback rate

AudioBufferSourceNode.playbackRate resamples the buffer and applies no pitch correction, so rate and pitch move together. That is what makes the technique work.

  • HTMLMediaElement.playbackRate corrects pitch by default. An <audio> element played faster gets shorter, not higher, unless you set preservesPitch = false. If you want pitch, use Web Audio or a library built on it
  • Randomising the rate across a wide band per trigger fakes a whole sample library from one file. Roughly 0.75 to 1.5 is a usable band for short percussive sounds. This is a different move from the plus or minus 3 percent nudge above, which only breaks an identical repeat
  • Mapping the rate to a running count gives a rising pitch as a counter climbs, with no recorded variants. Clamp the top of the range so it does not become a whistle, and reset when the counter resets
const MIN_RATE = 1;
const MAX_RATE = 2;
const CLIMB_OVER = 20;

function playClimbing(count) {
  const progress = Math.min(count / CLIMB_OVER, 1);
  play("glug", { rate: MIN_RATE + (MAX_RATE - MIN_RATE) * progress });
}
  • detune is the same control expressed in cents and is compounded with playbackRate. Plus or minus 100 is a semitone and plus or minus 1200 is an octave. Reach for it when you want musical intervals rather than an arbitrary band
  • The two units convert cleanly, so pick one and stay in it. The plus or minus 3 percent rate nudge from the variation section is about plus or minus 51 cents, or roughly half a semitone, if you would rather set detune
  • Rate changes wall-clock duration too. A 30ms segment at rate 2 finishes in 15ms. The offset and duration are measured in the buffer's natural sample rate, so the segment boundaries and the tail padding stay correct

Volume calibration

Interface feedback sits far lower than most people expect. Treat a number as a starting point and calibrate by ear.

  • Start at a gain of 0.25 and expect to move it per sound
  • Calibrate against real listening material: play unrelated audio at a normal listening level, then trigger your sound over it. It should sit barely audible underneath. If you notice it arriving, it is too loud
  • Source files vary widely in level. Two files at the same gain can be far apart, so per-sound adjustment is unavoidable and a single global number will not work
  • Calibrate on headphones at a normal level, not on laptop speakers at full. Speakers hide low-level detail and you will overshoot
  • Do not ladder gain by semantic importance. An error that is simply louder than a click is startling, not clearer. Importance should come from the character of the sample, its pitch, its length and its shape, and every sound should sit at roughly the same quiet level

Giving the user an off switch

A settings toggle is not enough, because nobody can pre-mute a surprise. The first sound is the one that catches people.

  • Ship a mute control inside the interface. Either a persistent global mute in the header, or a small mute affordance on each sound-producing component
  • Per-component mutes suit surfaces where only a few things make sound. A global mute suits a product where sound is everywhere
  • "Mute your device" is not an acceptable workaround. Someone may need their device audible for a call, an alarm or another person in the room, and silencing everything to escape your interface is a real cost
  • Persist the choice and read it before every playback, so a mute takes effect immediately rather than at the next page load
  • Make the control state readable. Use a real button with aria-pressed, and give it a label that names the current state rather than a bare icon
  • Defaulting to sound on is defensible, because the autoplay gate guarantees nothing plays before an interaction. Defaulting to on without a visible mute is not. The code below defaults to off, so an unset preference and a stored off behave the same and reduced motion is honoured on the first visit
export function MuteButton({
  muted,
  onToggle,
}: {
  muted: boolean;
  onToggle: () => void;
}) {
  return (
    <button
      type="button"
      aria-pressed={muted}
      onClick={onToggle}
      className="rounded-full p-2 text-muted-foreground hover:text-foreground"
    >
      <SpeakerIcon muted={muted} className="size-4" aria-hidden="true" />
      <span className="sr-only">{muted ? "Unmute sounds" : "Mute sounds"}</span>
    </button>
  );
}

Sound is never the only channel

  • Every audio cue needs a visible equivalent that carries the same meaning. A user who hears nothing must lose nothing functional
  • This is not only about deafness. Muted devices, noisy rooms, headphones out and the mute control you just shipped all produce the same silent experience
  • Announce state changes through the DOM so assistive technology reads them, using role="status" or an aria-live region. A sound is inaudible to a screen reader user and invisible to the accessibility tree
  • Nothing autoplays without interaction, including notification and alert sounds. If a background job finishes while the user has not touched the page, show it, do not sound it
  • prefers-reduced-motion is the nearest available signal for "damp the extra layer", and it is a proxy, not a statement about audio. Your own toggle is the source of truth, and reduced motion should suppress sound rather than being the only thing that can
function PaymentResult() {
  useEffect(() => {
    play("success");
  }, []);

  return (
    <div role="status" className="flex items-center gap-2">
      <CheckIcon className="size-4 text-primary" aria-hidden="true" />
      <span>Payment successful</span>
    </div>
  );
}
  • Keep the preference in one module-level value. play() is a plain function called from event handlers, so it cannot use a hook, and a hook cannot be the only place the answer lives. The module holds the state, play() reads it directly, and React subscribes to the same value
const STORAGE_KEY = "sound-enabled";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

const listeners = new Set<() => void>();

// The single source of truth. Silent until the browser says otherwise.
let enabled = false;

function readStoredPreference() {
  try {
    return window.localStorage.getItem(STORAGE_KEY) === "true";
  } catch {
    // Storage is disabled, so there is no stored choice to honour.
    return false;
  }
}

// Silence wins: sound plays only when the stored preference is on and
// reduced motion is off.
function refresh() {
  enabled = readStoredPreference() && !window.matchMedia(REDUCED_MOTION).matches;
  for (const notify of listeners) notify();
}

// play() calls this. No hook, no React, no re-render in the path.
export function soundEnabled() {
  return enabled;
}

// The mute button calls this. A storage event never fires in the document that
// performed the write, so tell this document yourself.
export function setSoundEnabled(next: boolean) {
  try {
    window.localStorage.setItem(STORAGE_KEY, String(next));
  } catch {
    // Storage is unavailable, so the choice cannot outlive the page.
  }
  refresh();
}

function subscribe(notify: () => void) {
  listeners.add(notify);
  return () => {
    listeners.delete(notify);
  };
}

// The React-facing view of the value above, not a second copy of it.
export function useSoundEnabled() {
  return useSyncExternalStore(subscribe, soundEnabled, () => false);
}

// Browser only. The server has no preference to read and no sound to play.
if (typeof window !== "undefined") {
  window.matchMedia(REDUCED_MOTION).addEventListener("change", refresh);
  window.addEventListener("storage", refresh);
  refresh();
}
  • Wire the mute control to setSoundEnabled, not to localStorage directly. In the component: const enabled = useSoundEnabled(), then <MuteButton muted={!enabled} onToggle={() => setSoundEnabled(!enabled)} />. A write that skips the publish leaves both the hook and play() on the stale value
  • Both signals feed one refresh, so reduced motion still suppresses sound whichever of the two changes last. The four combinations collapse to one rule: audible only when the stored preference is on and reduced motion is off
  • The storage event only fires in other documents of the same origin, so it covers a second tab and never the tab that pressed the button. That is why setSoundEnabled publishes as well. Keeping both listeners means one mute press silences every open tab
  • Subscribe once at module level rather than inside the hook. The value has to be right for play() whether or not a component is mounted, and the listeners belong to the module that owns the value
  • useSyncExternalStore takes a server snapshot as its third argument. Returning the silent default there keeps the server render and the hydrating client render identical, which is what avoids a hydration mismatch, and React re-reads the live value immediately after hydration
  • Wrap the storage read in a try block. Access throws outright when storage is disabled, and an unhandled throw here takes the whole component down over a sound preference

Ticks on sliders and continuous inputs

  • Listen for input, not change. The input event fires every time the value changes, while change fires only when the value is committed, which for a dragged slider means on release. Ticks wired to change sound once, at the end of the drag, which is the opposite of what a detent should do
  • React's onChange on an <input> behaves like the browser input event and fires on every change, so React code gets this right without knowing it. Plain DOM code does not, which is why this bug shows up specifically in vanilla listeners
  • Rate-limit the ticks. A drag fires input faster than the ear resolves the individual sounds, so play at most one tick per value step rather than one per event
let lastStep = null;

slider.addEventListener("input", (event) => {
  const step = Math.round(Number(event.target.value));
  if (step === lastStep) return;
  lastStep = step;
  play("tick", { rate: 0.9 + Math.random() * 0.4 });
});
  • The same rule covers number steppers, segmented controls and anything else dragged or held. Tie the sound to the value crossing a step, not to the event

Choosing and sourcing sounds

  • Clicks and taps: soft, quick, minimal sustain, neutral tone, well under a tenth of a second
  • Success: upward pitch or a major interval, pleasant, a short sustain
  • Errors: lower pitch, distinct but not harsh, a soft thunk rather than a beep
  • Notifications: gentle rather than attention-grabbing, two tones works well
  • Almost every interface sound should be under a second. Nothing loops, nothing plays in the background

Licences are per asset, not per site:

  • On free sound libraries a single file can be public domain, attribution required, or attribution plus non-commercial. Two sounds found on the same page can carry different terms
  • "Royalty free" on an aggregator does not reliably mean unrestricted. It is a marketing phrase, not a licence. Read the terms attached to the specific file before shipping it
  • Record the licence and the required attribution next to the file in your repo so it survives the person who downloaded it
  • Trim silence, export mono, and keep the clip short. A UI sound is a fraction of a second, so the file is small by nature once the dead air is gone. MP3 plays everywhere in practice; check support before relying on a newer codec

Common mistakes

  • Reusing one HTMLAudioElement and resetting currentTime = 0 before each play, so rapid triggers cut each other off and sample variation cannot work
  • Playing the first sound from a setTimeout or a promise callback instead of directly inside the activation handler, so it silently never fires
  • Wiring the first sound to hover or scroll, which are not activation events, so nothing plays until the user happens to click something else
  • Swallowing the play() rejection in an empty catch, which turns a site-wide NotAllowedError into a mystery
  • Letting a missing or undecodable file reject out of the loader, so one bad asset takes down the whole kit instead of costing one sound
  • Keeping the mute preference only in React state, so the plain playback function cannot read it and a mute applies to the icon but not to the audio
  • Testing only on a development machine the browser has already allowlisted, so the first-run gate never appears
  • Cutting a sprite segment at the end of the audible sound, which truncates the decay and adds a click
  • Using playbackRate on an <audio> element to shift pitch, which changes speed only because pitch is corrected by default
  • Setting notification volume higher than click volume, which makes the important sounds startling instead of clearer
  • Shipping only a settings-page toggle, so a first-time visitor has no way to stop a sound that already surprised them
  • Playing slider ticks on change in plain DOM code, so the whole drag is silent and one tick fires on release
  • Making a state change audible but not visible, so muted, deaf and screen reader users lose the information entirely
  • Looping or ambient audio, which is annoying at any volume and is the fastest way to get a site muted for good

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ux-principle({ topic: "sound" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems