yugodesign

Sound·11.2

Sound Toggle

The preference the whole palette answers to.

Interface soundMuted
sound-toggle

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-toggle.json

Usage

stats.tsx
"use client";

import { SoundToggle, useSoundToggle } from "@/components/yugo/sound-toggle";

export function PreferencesPanel() {
  return (
    <SoundToggle
      label="Interface sound"
      onChange={(enabled) => track("sound", { enabled })}
    />
  );
}

export function HeaderMuteButton() {
  const { enabled, switchProps } = useSoundToggle({ cue: "tick" });

  return (
    <button {...switchProps} aria-label="Interface sound">
      {enabled ? <SpeakerIcon /> : <MutedIcon />}
    </button>
  );
}

Source

components/yugo/sound-toggle.tsx
"use client";

import { useCallback, useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import {
  isEnabled,
  play,
  setEnabled as setEngineEnabled,
  type SoundName,
} from "@yugo/sound";

const THUMB = {
  type: "spring",
  stiffness: 620,
  damping: 42,
  mass: 0.6,
} as const;
const TRACK = {
  type: "spring",
  stiffness: 340,
  damping: 34,
  mass: 0.7,
} as const;
const INSTANT = { duration: 0 } as const;

const DEFAULT_KEY = "yugo-sound";
const TRAVEL = 18;

function readStored(key: string | null): boolean | null {
  if (!key || typeof window === "undefined") return null;
  try {
    const raw = window.localStorage.getItem(key);
    if (!raw) return null;
    const parsed: unknown = JSON.parse(raw);
    if (!parsed || typeof parsed !== "object") return null;
    const { enabled } = parsed as Record<string, unknown>;
    return typeof enabled === "boolean" ? enabled : null;
  } catch {
    return null;
  }
}

function writeStored(key: string | null, enabled: boolean) {
  if (!key || typeof window === "undefined") return;
  try {
    const raw = window.localStorage.getItem(key);
    const previous: unknown = raw ? JSON.parse(raw) : {};
    const base = previous && typeof previous === "object" ? previous : {};
    window.localStorage.setItem(key, JSON.stringify({ ...base, enabled }));
  } catch {
    return;
  }
}

export type UseSoundToggleOptions = {
  defaultEnabled?: boolean;
  storageKey?: string | null;
  cue?: SoundName | null;
  onChange?: (enabled: boolean) => void;
};

export function useSoundToggle({
  defaultEnabled = false,
  storageKey = DEFAULT_KEY,
  cue = "toggle",
  onChange,
}: UseSoundToggleOptions = {}) {
  const [enabled, setEnabledState] = useState(defaultEnabled);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    const stored = readStored(storageKey);
    const next = stored ?? defaultEnabled;
    setEnabledState(next);
    setEngineEnabled(next);
    setHydrated(true);
  }, [defaultEnabled, storageKey]);

  const setEnabled = useCallback(
    (next: boolean) => {
      setEnabledState(next);
      setEngineEnabled(next);
      writeStored(storageKey, next);
      if (next && cue) play(cue);
      onChange?.(next);
    },
    [cue, onChange, storageKey],
  );

  const toggle = useCallback(() => {
    setEnabled(!isEnabled());
  }, [setEnabled]);

  const switchProps = {
    type: "button" as const,
    role: "switch" as const,
    "aria-checked": enabled,
    onClick: toggle,
  };

  return { enabled, setEnabled, toggle, switchProps, hydrated };
}

function WaveIcon({ on }: { on: boolean }) {
  return (
    <svg viewBox="0 0 16 16" width="11" height="11" fill="none" aria-hidden>
      {on ? (
        <path
          d="M3 8h1.6l1.5-3.6L8.6 12l1.6-5.4L11.6 8H13"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      ) : (
        <path
          d="M3 8h10"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
        />
      )}
    </svg>
  );
}

export type SoundToggleProps = {
  label?: string;
  hint?: string;
  defaultEnabled?: boolean;
  storageKey?: string | null;
  cue?: SoundName | null;
  onChange?: (enabled: boolean) => void;
  disabled?: boolean;
  className?: string;
};

export function SoundToggle({
  label = "Interface sound",
  hint,
  defaultEnabled = false,
  storageKey = DEFAULT_KEY,
  cue = "toggle",
  onChange,
  disabled = false,
  className = "",
}: SoundToggleProps) {
  const labelId = useId();
  const hintId = useId();
  const reduced = useReducedMotion();
  const { enabled, switchProps } = useSoundToggle({
    defaultEnabled,
    storageKey,
    cue,
    onChange,
  });

  return (
    <div
      className={`flex w-full items-center justify-between gap-6 ${className}`}
    >
      <span className="grid min-w-0 gap-0.5">
        <span
          id={labelId}
          className="truncate text-[13px] text-stone-800 dark:text-stone-100"
        >
          {label}
        </span>
        <span className="grid">
          <span
            aria-hidden
            className="invisible col-start-1 row-start-1 text-[11.5px]"
          >
            {hint ?? "Muted"}
          </span>
          <span
            id={hintId}
            className="col-start-1 row-start-1 truncate text-[11.5px] text-stone-500 dark:text-stone-400"
          >
            {hint ?? (enabled ? "On" : "Muted")}
          </span>
        </span>
      </span>

      <button
        {...switchProps}
        disabled={disabled}
        aria-labelledby={labelId}
        aria-describedby={hintId}
        className="relative h-[24px] w-[42px] shrink-0 rounded-[8px] outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:shadow-[0_0_0_2px_#4568FF] dark:focus-visible:shadow-[0_0_0_2px_#93B0FF]"
      >
        <motion.span
          aria-hidden
          initial={false}
          animate={{
            backgroundColor: enabled
              ? "#4568FF"
              : "rgba(120, 113, 108, 0.28)",
          }}
          transition={reduced ? INSTANT : TRACK}
          className="absolute inset-0 rounded-[8px] shadow-[inset_0_1px_2px_rgba(0,0,0,0.12)]"
        />
        <motion.span
          aria-hidden
          initial={false}
          animate={{ x: enabled ? TRAVEL : 0 }}
          transition={reduced ? INSTANT : THUMB}
          className="absolute left-[3px] top-[3px] grid size-[18px] place-items-center rounded-[6px] bg-white text-stone-500 shadow-[0_1px_2px_rgba(0,0,0,0.24)]"
        >
          <WaveIcon on={enabled} />
        </motion.span>
      </button>
    </div>
  );
}

Props

label"Interface sound"
string

The switch's accessible name, wired through aria-labelledby rather than repeated as a bare string.

hint
string

Replaces the state word underneath. Leave it out to get On and Muted, which is usually what a settings row wants.

defaultEnabledfalse
boolean

State before storage is read. Off, for the same reason a phone ships on silent.

storageKey"yugo-sound"
string | null

Shared with Sound Cues. Writing merges into the stored object, so the volume the palette saved survives a toggle.

cue"toggle"
SoundName | null

Played when sound is switched on. Pass null for a silent switch.

onChange
(enabled: boolean) => void

Fires after the engine and storage have both been updated, so a listener never reads a state that has not landed.

disabledfalse
boolean

Refuses to flip, and drops the pointer events rather than only dimming.

className""
string

Appended last to the row.