yugodesign

Sound·11.1

Sound Cues

Fourteen cues, auditioned before you commit to one.

Cues
Waveformno signal

Pick a cue to hear it. Arrow keys walk the list, Enter plays.

sound-cues

Install

2 dependencies. The component is copied into your project, so the file is yours after that.

terminal
bun add @yugo/sound motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/sound-cues.json

Usage

stats.tsx
"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

components/yugo/sound-cues.tsx
"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

defaultEnabledfalse
boolean

Whether sound is on before anything is read from storage. Off is the right default: audio reaches people who did not ask for it.

defaultVolume0.7
number

Starting global multiplier, clamped to 0–1. Overridden by a stored preference if there is one.

storageKey"yugo-sound"
string | null

localStorage key holding { enabled, volume }. Pass null to keep the preference in memory only, which is what a docs demo wants.

onCue
(name: SoundName) => void

Fires when a row is auditioned. Not the same as the engine's subscribe, which also reports cues fired from anywhere else.

className""
string

Appended last to the outer frame, so a caller's width and surface win.