# Sound Toggle

A speaker button that turns interface sounds on or off: waves ripple out while it's on and a slash draws across when it's off. Remembers the choice and switches UI Sounds everywhere on the page.

**Category:** Sounds  
**Exports:** `SoundToggle`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/sound-toggle.json
```
The file lands at `components/ui/sound-toggle.tsx`; import from `@/components/ui/sound-toggle`.

## API — Sound Toggle
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `onChange` | `(on: boolean) => void` | — |  |
| `size` | `"sm" \| "md"` | `"md"` |  |
| `className` | `string` | — |  |

## Example — Sound Toggle

```tsx
import { SoundToggle } from "@/components/ui/sound-toggle";

export default function SoundToggleDemo({ size = "md" }: { size?: "sm" | "md" }) {
  return (
    <div className="flex items-center gap-3">
      <SoundToggle size={size} />
      <span className="text-sm text-muted-foreground">Interface sounds</span>
    </div>
  );
}
```

## Source — components/ui/sound-toggle.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type SoundToggleProps = {
  /** Called with the new state. */
  onChange?: (on: boolean) => void;
  size?: "sm" | "md";
  className?: string;
};

// Shared with ui-sounds, so this button switches every sound on the page.
const KEY = "charcoal-sounds";
const EVENT = "charcoal-sounds-change";

/** A speaker button that turns interface sounds on or off: sound waves ripple out when it's on, and a slash draws across when it's off. The choice is remembered. */
export function SoundToggle({ onChange, size = "md", className }: SoundToggleProps) {
  const reduce = useReducedMotion();
  const [on, setOn] = React.useState(true);

  React.useEffect(() => {
    try {
      setOn(localStorage.getItem(KEY) !== "off");
    } catch {
      /* storage blocked: default on */
    }
    const sync = (e: Event) => setOn((e as CustomEvent<boolean>).detail);
    window.addEventListener(EVENT, sync);
    return () => window.removeEventListener(EVENT, sync);
  }, []);

  const toggle = () => {
    const next = !on;
    setOn(next);
    try {
      localStorage.setItem(KEY, next ? "on" : "off");
    } catch {
      /* the change still applies to this page */
    }
    window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
    onChange?.(next);
  };

  const s = size === "sm" ? 32 : 40;
  return (
    <button type="button" role="switch" aria-checked={on} aria-label="Interface sounds" onClick={toggle} className={cn("relative grid cursor-pointer place-items-center rounded-xl border bg-card text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", className)} style={{ width: s, height: s }}>
      <svg viewBox="0 0 24 24" width={s * 0.5} height={s * 0.5} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden className="overflow-visible">
        <path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
        <AnimatePresence initial={false}>
          {on ? (
            <motion.g key="on" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
              {["M16 9a5 5 0 0 1 0 6", "M19.364 18.364a9 9 0 0 0 0-12.728"].map((d, i) => (
                <motion.path key={d} d={d} initial={{ pathLength: 0 }} animate={reduce ? { pathLength: 1 } : { pathLength: 1, opacity: [1, 0.35, 1] }} transition={{ pathLength: { duration: 0.25, delay: i * 0.08 }, opacity: { duration: 1.6, delay: 0.4 + i * 0.25, repeat: Infinity, ease: "easeInOut" } }} />
              ))}
            </motion.g>
          ) : (
            <motion.path key="off" d="M22 9l-6 6M16 9l6 6" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.22 }} />
          )}
        </AnimatePresence>
      </svg>
    </button>
  );
}
```