Sound·11.2
Sound Toggle
The preference the whole palette answers to.
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-toggle.jsonUsage
"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
"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"stringThe switch's accessible name, wired through aria-labelledby rather than repeated as a bare string.
hintstringReplaces the state word underneath. Leave it out to get On and Muted, which is usually what a settings row wants.
defaultEnabledfalsebooleanState before storage is read. Off, for the same reason a phone ships on silent.
storageKey"yugo-sound"string | nullShared with Sound Cues. Writing merges into the stored object, so the volume the palette saved survives a toggle.
cue"toggle"SoundName | nullPlayed when sound is switched on. Pass null for a silent switch.
onChange(enabled: boolean) => voidFires after the engine and storage have both been updated, so a listener never reads a state that has not landed.
disabledfalsebooleanRefuses to flip, and drops the pointer events rather than only dimming.
className""stringAppended last to the row.