UI sounds
Twelve sounds for every component’s actions, and the player that plays them.
Install
components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.bunx shadcn@3 add @fastrp/ui-soundsExamples
Default
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
| onSound* | (sound: UiSound) => void | — |
<UI_SOUND_DATA />Takes the props of what it wrapsThe 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.tsxShowHide
'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.tsShowHide
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 */,
};