Sound·11.1
Sound Cues
Fourteen cues, auditioned before you commit to one.
Pick a cue to hear it. Arrow keys walk the list, Enter plays.
Install
2 dependencies. The component is copied into your project, so the file is yours after that.
bun add @yugo/sound motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/sound-cues.jsonUsage
"use client";
import { SoundCues, useSoundCues } from "@/components/yugo/sound-cues";
export function SoundSettings() {
return <SoundCues defaultVolume={0.6} />;
}
export function SaveBar({ onSave }: { onSave: () => Promise<void> }) {
const { play, enabled } = useSoundCues();
async function submit() {
try {
await onSave();
play("success");
} catch {
play("error");
}
}
return (
<button onClick={submit} aria-describedby={enabled ? undefined : "muted"}>
Save
</button>
);
}Source
"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { motion, useReducedMotion } from "motion/react";
import {
getVolume,
isEnabled,
play as playCue,
setEnabled as setEngineEnabled,
setVolume as setEngineVolume,
sounds,
subscribe,
type SoundName,
} from "@yugo/sound";
const MARK = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.7,
} as const;
const FLASH = {
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.6,
} as const;
const INSTANT = { duration: 0 } as const;
const CHARACTER: Record<SoundName, string> = {
chime: "Soft two-note ascending bell",
sparkle: "Quick four-note twinkle",
droplet: "Single note gliding down",
bloom: "Warm slow swell",
whisper: "Breathy quiet swell",
tick: "Crisp instant tick",
press: "Dull muted knock",
release: "Brighter springy tick",
toggle: "Mechanical click-clack",
success: "Warm three-note confirmation",
error: "Soft knock and descending refusal",
page: "Papery flick with a glass tick",
loading: "Brief unresolved rising shimmer",
ready: "Focus tick with a harmonic bloom",
};
const DEFAULT_VOLUME = 0.7;
const DEFAULT_KEY = "yugo-sound";
function clamp(value: number) {
return Math.min(1, Math.max(0, value));
}
function readStored(key: string | null): { enabled?: boolean; volume?: number } {
if (!key || typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(key);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return {};
const { enabled, volume } = parsed as Record<string, unknown>;
return {
enabled: typeof enabled === "boolean" ? enabled : undefined,
volume: typeof volume === "number" && Number.isFinite(volume) ? clamp(volume) : undefined,
};
} catch {
return {};
}
}
function writeStored(key: string | null, enabled: boolean, volume: number) {
if (!key || typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify({ enabled, volume }));
} catch {
return;
}
}
export type Cue = {
name: SoundName;
character: string;
};
export type UseSoundCuesOptions = {
defaultEnabled?: boolean;
defaultVolume?: number;
storageKey?: string | null;
};
export function useSoundCues({
defaultEnabled = false,
defaultVolume = DEFAULT_VOLUME,
storageKey = DEFAULT_KEY,
}: UseSoundCuesOptions = {}) {
const [enabled, setEnabledState] = useState(defaultEnabled);
const [volume, setVolumeState] = useState(() => clamp(defaultVolume));
const [hydrated, setHydrated] = useState(false);
const [lastCue, setLastCue] = useState<SoundName | null>(null);
const [plays, setPlays] = useState(0);
useEffect(() => {
const stored = readStored(storageKey);
const nextEnabled = stored.enabled ?? defaultEnabled;
const nextVolume = stored.volume ?? clamp(defaultVolume);
setEnabledState(nextEnabled);
setVolumeState(nextVolume);
setEngineEnabled(nextEnabled);
setEngineVolume(nextVolume);
setHydrated(true);
}, [defaultEnabled, defaultVolume, storageKey]);
useEffect(
() =>
subscribe((sound) => {
setLastCue(sound);
setPlays((n) => n + 1);
}),
[],
);
const setEnabled = useCallback(
(next: boolean) => {
setEnabledState(next);
setEngineEnabled(next);
writeStored(storageKey, next, getVolume());
},
[storageKey],
);
const setVolume = useCallback(
(next: number) => {
const settled = clamp(next);
setVolumeState(settled);
setEngineVolume(settled);
writeStored(storageKey, isEnabled(), settled);
},
[storageKey],
);
const play = useCallback((name: SoundName, options?: { volume?: number }) => {
playCue(name, options);
}, []);
const cues = useMemo<Cue[]>(
() => sounds.map((name) => ({ name, character: CHARACTER[name] })),
[],
);
return {
cues,
enabled,
setEnabled,
volume,
setVolume,
play,
lastCue,
plays,
hydrated,
};
}
function SpeakerIcon({ muted }: { muted: boolean }) {
return (
<svg viewBox="0 0 20 20" width="13" height="13" fill="none" aria-hidden>
<path
d="M10.5 3.8 6.6 7H4.2a.7.7 0 0 0-.7.7v4.6a.7.7 0 0 0 .7.7h2.4l3.9 3.2Z"
fill="currentColor"
/>
{muted ? (
<path
d="m13.6 8 3.4 4M17 8l-3.4 4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
) : (
<path
d="M13.4 7.4a3.6 3.6 0 0 1 0 5.2M15.6 5.4a6.6 6.6 0 0 1 0 9.2"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
)}
</svg>
);
}
export type SoundCuesProps = {
defaultEnabled?: boolean;
defaultVolume?: number;
storageKey?: string | null;
onCue?: (name: SoundName) => void;
className?: string;
};
export function SoundCues({
defaultEnabled = false,
defaultVolume = DEFAULT_VOLUME,
storageKey = DEFAULT_KEY,
onCue,
className = "",
}: SoundCuesProps) {
const labelId = useId();
const reduced = useReducedMotion();
const { cues, enabled, setEnabled, play, lastCue, plays } = useSoundCues({
defaultEnabled,
defaultVolume,
storageKey,
});
const [active, setActive] = useState(0);
const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
const audition = useCallback(
(index: number) => {
const cue = cues[index];
if (!cue) return;
setActive(index);
if (!enabled) setEnabled(true);
play(cue.name);
onCue?.(cue.name);
},
[cues, enabled, onCue, play, setEnabled],
);
const focusRow = useCallback((index: number) => {
setActive(index);
rowRefs.current[index]?.focus();
}, []);
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const last = cues.length - 1;
if (event.key === "ArrowDown") focusRow(Math.min(last, active + 1));
else if (event.key === "ArrowUp") focusRow(Math.max(0, active - 1));
else if (event.key === "Home") focusRow(0);
else if (event.key === "End") focusRow(last);
else return;
event.preventDefault();
}
return (
<div
className={`w-full text-stone-800 dark:text-stone-100 ${className}`}
>
<div className="mb-2 flex items-center justify-between gap-3">
<span id={labelId} className="text-[12.5px] text-stone-500 dark:text-stone-400">
Cues
</span>
<button
type="button"
onClick={() => setEnabled(!enabled)}
aria-pressed={enabled}
className="inline-flex h-[22px] items-center gap-1.5 rounded-[6px] px-1.5 text-[11px] text-stone-500 outline-none hover:text-stone-800 focus-visible:shadow-[0_0_0_2px_#4568FF] dark:text-stone-400 dark:hover:text-stone-100 dark:focus-visible:shadow-[0_0_0_2px_#93B0FF]"
>
<SpeakerIcon muted={!enabled} />
{enabled ? "On" : "Muted"}
</button>
</div>
<div
role="listbox"
tabIndex={-1}
aria-labelledby={labelId}
onKeyDown={onKeyDown}
className="max-h-[268px] overflow-y-auto overscroll-contain rounded-[10px] bg-stone-100 p-1 dark:bg-white/[0.06]"
>
{cues.map((cue, index) => {
const selected = index === active;
return (
<button
key={cue.name}
type="button"
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
ref={(node) => {
rowRefs.current[index] = node;
}}
onClick={() => audition(index)}
onFocus={() => setActive(index)}
className={`relative flex h-[32px] w-full items-center gap-2.5 rounded-[7px] px-2 text-left outline-none focus-visible:shadow-[inset_0_0_0_2px_#4568FF] dark:focus-visible:shadow-[inset_0_0_0_2px_#93B0FF] ${
selected ? "" : "hover:bg-stone-200/70 dark:hover:bg-white/[0.06]"
}`}
>
{selected && (
<motion.span
layoutId={`${labelId}-row`}
aria-hidden
transition={reduced ? INSTANT : MARK}
className="absolute inset-0 rounded-[7px] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.06)] dark:bg-white/10 dark:shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
/>
)}
<motion.span
key={cue.name === lastCue ? plays : "idle"}
aria-hidden
initial={reduced ? false : { scale: 0.6, opacity: 0.5 }}
animate={{ scale: 1, opacity: 1 }}
transition={reduced ? INSTANT : FLASH}
className="relative block size-[6px] shrink-0 rounded-[2px]"
style={{
background:
cue.name === lastCue
? "#4568FF"
: selected
? "currentColor"
: "rgb(168 162 158)",
}}
/>
<span
className={`relative truncate text-[13px] ${
selected ? "font-medium" : "text-stone-600 dark:text-stone-300"
}`}
>
{cue.name}
</span>
<span className="relative ml-auto hidden truncate text-[11px] text-stone-500 sm:block dark:text-stone-400">
{cue.character}
</span>
</button>
);
})}
</div>
</div>
);
}Props
defaultEnabledfalsebooleanWhether sound is on before anything is read from storage. Off is the right default: audio reaches people who did not ask for it.
defaultVolume0.7numberStarting global multiplier, clamped to 0–1. Overridden by a stored preference if there is one.
storageKey"yugo-sound"string | nulllocalStorage key holding { enabled, volume }. Pass null to keep the preference in memory only, which is what a docs demo wants.
onCue(name: SoundName) => voidFires when a row is auditioned. Not the same as the engine's subscribe, which also reports cues fired from anywhere else.
className""stringAppended last to the outer frame, so a caller's width and surface win.