Component

UI sounds

Twelve sounds for every component’s actions, and the player that plays them.

stablev0.5.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/ui-sounds

Examples

Default

Open alone

API

<UiSoundsProvider />

Where the components report their sounds; `onSound` decides what plays. Without a provider every component is silent, so an app opts in once, at its root. Besides the components' own calls, the outermost provider listens on the document for two attributes, so a plain element — or a server-rendered button — can sound without a hook: `data-sfx="select"` plays on click (`data-sfx="toggle"` picks `toggle-on` or `toggle-off` from the element's `aria-checked`), and `data-sfx-hover="navigate"` plays when the mouse moves onto it. Disabled elements are silent.

PropTypeDefaultNotes
onSound*(sound: UiSound) => void—
<UI_SOUND_DATA />Takes the props of what it wraps

The twelve sounds as Ogg Vorbis, base64. Generated by `bun scripts/build-sfx.ts` from `assets/sfx/`, where their sources are listed. Do not edit by hand. Credits an app that ships them must carry: "Some of the sounds in this project were created by David McKee (ViRiX) soundcloud.com/virix" (CC-BY 3.0), and Paolo Migliorisi (Urizen), UI Sci-Fi SFXs Confirm Pack Vol 1 (OGA-BY 4.0). The rest are CC0: Lokif, bosslevelaudio.

Dependencies

npm

  • None

Registry

  • None

Source

components/ds/ui-sounds.tsxShow
'use client';

import * as React from 'react';

// @fast-ds [email protected]

/**
 * Every moment the interface makes a sound. Twelve, and no more: a sound that means nothing in
 * particular is noise.
 *
 * - `navigate` — the cursor moved: a row, a menu item, a button under the pointer.
 * - `adjust` — a value stepped: a stepper, a slider, a swatch, a choice.
 * - `tab` — a section switched.
 * - `select` — something was pressed or confirmed.
 * - `back` — a step back out of a sub-menu.
 * - `toggle-on` / `toggle-off` — a switch or a checkbox.
 * - `open` / `close` — a dialog, a sheet, a dropdown, a menu screen.
 * - `success` / `error` — the outcome the server answered with; the app plays these.
 * - `notify` — something arrived unasked: a toast.
 */
export type UiSound =
  | 'navigate'
  | 'adjust'
  | 'tab'
  | 'select'
  | 'back'
  | 'toggle-on'
  | 'toggle-off'
  | 'open'
  | 'close'
  | 'success'
  | 'error'
  | 'notify';

export const UI_SOUNDS: readonly UiSound[] = [
  'navigate',
  'adjust',
  'tab',
  'select',
  'back',
  'toggle-on',
  'toggle-off',
  'open',
  'close',
  'success',
  'error',
  'notify',
];

/**
 * One action, one sound. A sound arriving within `WINDOW_MS` of a weightier one is dropped — the
 * dropdown item's `select` swallows the `close` that follows it, a dialog's `open` swallows the
 * `navigate` of its first focused item — and a repeat of the same sound within `REPEAT_MS` (a held
 * arrow key) is dropped too. `open` weighs as much as `select`: the button that opens a dialog
 * clicks, and the dialog still arrives with its own sound.
 */
const WEIGHT: Record<UiSound, number> = {
  navigate: 0,
  adjust: 1,
  tab: 2,
  'toggle-on': 2,
  'toggle-off': 2,
  open: 3,
  close: 2,
  select: 3,
  back: 3,
  notify: 4,
  success: 4,
  error: 4,
};
const WINDOW_MS = 140;
const REPEAT_MS = 45;

type Play = (sound: UiSound) => void;

const SoundContext = React.createContext<Play | null>(null);

const isUiSound = (name: string | null): name is UiSound =>
  name !== null && (UI_SOUNDS as readonly string[]).includes(name);

const isEnabled = (element: Element) =>
  !(element as HTMLButtonElement).disabled && element.getAttribute('aria-disabled') !== 'true';

/**
 * Where the components report their sounds; `onSound` decides what plays. Without a provider every
 * component is silent, so an app opts in once, at its root.
 *
 * Besides the components' own calls, the outermost provider listens on the document for two
 * attributes, so a plain element — or a server-rendered button — can sound without a hook:
 * `data-sfx="select"` plays on click (`data-sfx="toggle"` picks `toggle-on` or `toggle-off` from the
 * element's `aria-checked`), and `data-sfx-hover="navigate"` plays when the mouse moves onto it.
 * Disabled elements are silent.
 */
function UiSoundsProvider({
  onSound,
  children,
}: {
  onSound: (sound: UiSound) => void;
  children: React.ReactNode;
}) {
  const parent = React.useContext(SoundContext);
  // A ref keeps the context value stable, so an inline handler does not re-render every row.
  const handler = React.useRef(onSound);
  handler.current = onSound;
  const last = React.useRef<{ sound: UiSound; at: number } | null>(null);

  const play = React.useCallback<Play>((sound) => {
    const now = performance.now();
    const previous = last.current;
    if (previous && now - previous.at < WINDOW_MS) {
      if (previous.sound === sound && now - previous.at < REPEAT_MS) return;
      if (WEIGHT[sound] < WEIGHT[previous.sound]) return;
    }
    last.current = { sound, at: now };
    handler.current(sound);
  }, []);

  // A nested provider (an example logging its own sounds) leaves the document to the outer one, so
  // a click is never heard twice.
  const nested = parent !== null;
  React.useEffect(() => {
    if (nested) return;
    let hovered: Element | null = null;

    const onClick = (event: MouseEvent) => {
      const element = (event.target as Element | null)?.closest?.('[data-sfx]');
      if (!element || !isEnabled(element)) return;
      const name = element.getAttribute('data-sfx');
      if (name === 'toggle') {
        // Capture runs before the element flips, so this is the state being left.
        play(element.getAttribute('aria-checked') === 'true' ? 'toggle-off' : 'toggle-on');
      } else if (isUiSound(name)) {
        play(name);
      }
    };

    // The browser also sends `pointerover` when the page changes under a still mouse — a dialog
    // closing uncovers its trigger. Only a pointer that moved is the player's doing.
    let x = Number.NaN;
    let y = Number.NaN;
    const moved = (event: PointerEvent) => {
      const changed = event.clientX !== x || event.clientY !== y;
      x = event.clientX;
      y = event.clientY;
      return changed;
    };

    const onOver = (event: PointerEvent) => {
      if (event.pointerType !== 'mouse') return;
      const element = (event.target as Element | null)?.closest?.('[data-sfx-hover]') ?? null;
      const real = moved(event);
      if (element === hovered) return;
      hovered = element;
      if (!real || !element || !isEnabled(element)) return;
      const name = element.getAttribute('data-sfx-hover');
      if (isUiSound(name)) play(name);
    };

    document.addEventListener('click', onClick, true);
    document.addEventListener('pointerover', onOver, true);
    document.addEventListener('pointermove', moved, { capture: true, passive: true });
    return () => {
      document.removeEventListener('click', onClick, true);
      document.removeEventListener('pointerover', onOver, true);
      document.removeEventListener('pointermove', moved, { capture: true });
    };
  }, [nested, play]);

  return <SoundContext.Provider value={play}>{children}</SoundContext.Provider>;
}

const silent: Play = () => {};

/** The function that plays a sound — silent when no provider is mounted. */
function useUiSound(): Play {
  return React.useContext(SoundContext) ?? silent;
}

/**
 * An overlay root's open state, made audible: `open` when it opens and `close` when it closes,
 * however that happened — a trigger, ESC, a click outside, or the app's own `open` prop. Works for
 * a controlled root and an uncontrolled one alike; hand the result to the Radix root.
 */
function useSoundedOpen({
  open,
  defaultOpen,
  onOpenChange,
}: {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
}): { open: boolean; onOpenChange: (open: boolean) => void } {
  const sound = useUiSound();
  const [inner, setInner] = React.useState(defaultOpen ?? false);
  const current = open ?? inner;

  // Opening with the page is not an action; only a change is. A ref of the last state, not a
  // first-render flag, so StrictMode's second effect run stays quiet too.
  const previous = React.useRef(current);
  React.useEffect(() => {
    if (previous.current === current) return;
    previous.current = current;
    sound(current ? 'open' : 'close');
  }, [current, sound]);

  const change = React.useCallback(
    (next: boolean) => {
      if (open === undefined) setInner(next);
      onOpenChange?.(next);
    },
    [open, onOpenChange],
  );

  return { open: current, onOpenChange: change };
}

export type UiSoundPlayerOptions = {
  /** 0–1, every sound. Default 0.35 — a faint confirmation under the game's own audio. */
  volume?: number;
  /** A multiplier per sound; `0` mutes one (`{ navigate: 0 }` for a site that should not tick). */
  volumes?: Partial<Record<UiSound, number>>;
  /** An existing context to play through — the game webview has one shared `AudioContext`. */
  context?: () => AudioContext;
};

export type UiSoundPlayer = {
  play: (sound: UiSound) => void;
  setVolume: (volume: number) => void;
  /** Decode every sound now rather than on the first one. */
  preload: () => Promise<void>;
};

function decodeBase64(data: string): ArrayBuffer {
  const binary = atob(data);
  const bytes = new Uint8Array(binary.length);
  for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
  return bytes.buffer;
}

/**
 * Plays the bundled sounds through Web Audio, for `UiSoundsProvider`'s `onSound`. Two of their
 * authors ask for a credit, which an app shipping them carries — see `ui-sounds-data.ts`. The sounds load
 * with the first one played (about 85 KB, split out of the main bundle), and each play is a
 * buffer source, so a fast run of `navigate` never queues or cuts itself off.
 *
 * ```tsx
 * const player = createUiSoundPlayer();
 * <UiSoundsProvider onSound={player.play}>…</UiSoundsProvider>
 * ```
 */
function createUiSoundPlayer({
  volume = 0.35,
  volumes,
  context,
}: UiSoundPlayerOptions = {}): UiSoundPlayer {
  let level = volume;
  let audio: AudioContext | null = null;
  let buffers: Promise<Map<UiSound, AudioBuffer>> | null = null;

  const getContext = () => {
    audio ??= context?.() ?? new AudioContext();
    return audio;
  };

  const load = () => {
    buffers ??= import('@/components/ds/ui-sounds-data').then(async ({ UI_SOUND_DATA }) => {
      const target = getContext();
      const decoded = await Promise.all(
        UI_SOUNDS.map(
          async (sound) =>
            [sound, await target.decodeAudioData(decodeBase64(UI_SOUND_DATA[sound]))] as const,
        ),
      );
      return new Map(decoded);
    });
    return buffers;
  };

  const play = (sound: UiSound) => {
    const gain = level * (volumes?.[sound] ?? 1);
    if (gain <= 0) return;
    load()
      .then((map) => {
        const buffer = map.get(sound);
        const target = getContext();
        if (!buffer) return;
        // Created before the first gesture, a context starts suspended; any later input resumes it.
        if (target.state === 'suspended') void target.resume();
        const source = target.createBufferSource();
        const volumeNode = target.createGain();
        source.buffer = buffer;
        volumeNode.gain.value = gain;
        source.connect(volumeNode).connect(target.destination);
        source.start();
      })
      .catch(() => {
        // No audio device, or decoding refused: the interface stays silent rather than break.
      });
  };

  return {
    play,
    setVolume: (next) => {
      level = Math.min(1, Math.max(0, next));
    },
    preload: () => load().then(() => undefined),
  };
}

export { createUiSoundPlayer, UiSoundsProvider, useSoundedOpen, useUiSound };
components/ds/ui-sounds-data.tsShow
import type { UiSound } from '@/components/ds/ui-sounds';

// fast-compat-ignore-file — base64 audio, nothing here is CSS.

/**
 * The twelve sounds as Ogg Vorbis, base64. Generated by `bun scripts/build-sfx.ts` from
 * `assets/sfx/`, where their sources are listed. Do not edit by hand.
 *
 * Credits an app that ships them must carry: "Some of the sounds in this project were created by
 * David McKee (ViRiX) soundcloud.com/virix" (CC-BY 3.0), and Paolo Migliorisi (Urizen), UI Sci-Fi
 * SFXs Confirm Pack Vol 1 (OGA-BY 4.0). The rest are CC0: Lokif, bosslevelaudio.
 */
export const UI_SOUND_DATA: Record<UiSound, string> = {
  adjust:
    'T2dnUwACAAAAAAAAAABNTEIFAAAAAATi…' /* 4.7 KB */,
  back: 'T2dnUwACAAAAAAAAAAAlTBrqAAAAAAGC…' /* 7.6 KB */,
  close:
    'T2dnUwACAAAAAAAAAACVy4nwAAAAAPbs…' /* 7.3 KB */,
  error:
    'T2dnUwACAAAAAAAAAAD+WepbAAAAACPi…' /* 5.7 KB */,
  navigate:
    'T2dnUwACAAAAAAAAAADLrrPuAAAAAFd1…' /* 5.0 KB */,
  notify:
    'T2dnUwACAAAAAAAAAABKcp+5AAAAAE+J…' /* 6.9 KB */,
  open: 'T2dnUwACAAAAAAAAAAB+OZipAAAAAF/e…' /* 8.1 KB */,
  select:
    'T2dnUwACAAAAAAAAAAAu/OVPAAAAAOjq…' /* 7.1 KB */,
  success:
    'T2dnUwACAAAAAAAAAACq1Xh9AAAAAFN1…' /* 8.6 KB */,
  tab: 'T2dnUwACAAAAAAAAAAAih6HfAAAAACMO…' /* 6.7 KB */,
  'toggle-off':
    'T2dnUwACAAAAAAAAAABy5OZ7AAAAAOd0…' /* 6.9 KB */,
  'toggle-on':
    'T2dnUwACAAAAAAAAAAAyG7EtAAAAABhl…' /* 6.5 KB */,
};