Sound Toggle
A single-button mute/unmute control. Swaps between a speaker-with-waves glyph and a speaker-with-X glyph with a soft scale-and-fade transition driven by Motion. Three visual variants (solid, ghost, outline), three sizes (sm, md, lg), and Radix-style controlled / uncontrolled APIs. Pairs naturally with the craft-bits useSound hook.
Customize
Style
ghost
md
Installation
npx shadcn@latest add https://craftbits.dev/r/sound-toggle.jsonUsage
Uncontrolled — the button owns its own state:
import { SoundToggle } from "@craft-bits/core";
<SoundToggle defaultEnabled onEnabledChange={(next) => console.log(next)} />Paired with useSound — one source of truth gates both the icon and the audio:
import { SoundToggle, useSound } from "@craft-bits/core";
const { enabled, setEnabled } = useSound();
<SoundToggle enabled={enabled} onEnabledChange={setEnabled} />Understanding the component
- Pairs naturally with
useSound. The companionuseSoundhook exposes{ enabled, setEnabled }withlocalStoragepersistence and a reduced-motion guard. Wire those intoSoundToggleand the same value gates both the icon and the actual audio output — wikia11y-toggle-setting("every sound has a global off switch"). - Glyph swap, not path tween. The on glyph is a speaker with two arc-wave strokes; the off glyph is the same speaker with an X. The topologies differ enough that tweening the
dstring produces visible knot-flips. We render onemotion.pathand swap itsdwith akeychange — Motion's per-keyinitial/animatescales the new glyph in from0.6and fades it from0. SameSPRINGS.snapas the button's tap response, so the icon flip and the press share a tempo. - Shared speaker body. Both glyphs share the same speaker-body subpath, so the swap reads as "the same icon got muted" rather than two unrelated icons crossfading.
- Controlled + uncontrolled.
enabled+onEnabledChangefor controlled,defaultEnabled+onEnabledChangefor uncontrolled. Same convention as@radix-ui/react-toggle. - Action-shaped labels.
aria-labeldescribes the action the click performs —"Mute"when sound is on,"Unmute"when sound is off — because screen readers announce the label as the button's affordance, not its current state.
Variants
<SoundToggle variant="solid" />
<SoundToggle variant="ghost" />
<SoundToggle variant="outline" />Sizes:
<SoundToggle size="sm" />
<SoundToggle size="md" />
<SoundToggle size="lg" />Disabled — drops the tap gesture and the click handler:
<SoundToggle disabled />Localized labels:
<SoundToggle labels={{ on: "Silence", off: "Resume audio" }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
enabled | boolean | — | Controlled enabled state. Pair with onEnabledChange. |
defaultEnabled | boolean | true | Uncontrolled initial enabled state. |
onEnabledChange | (next: boolean) => void | — | Fired with the new state when the user toggles. |
variant | 'solid' | 'ghost' | 'outline' | 'ghost' | Visual style of the button shell. |
size | 'sm' | 'md' | 'lg' | 'md' | Touch-target diameter (32 / 40 / 48 px). |
labels | { on?: string; off?: string } | { on: "Mute", off: "Unmute" } | Override the accessible labels (action-shaped). |
disabled | boolean | false | Disables the button and drops the tap gesture. |
className | string | — | Merged onto the rendered <button> via cn(). |
...rest | HTMLMotionProps<'button'> | — | Any other motion.button prop. |
Accessibility
- Renders a real
<button type="button">witharia-pressedreflecting the current state — screen readers announce both the action ("Mute" / "Unmute") and the toggle state. - Keyboard activation:
EnterandSpacetoggle the button, same as any native<button>. - Focus is visible via a
focus-visible:ring keyed to--cb-accent. - Minimum hit area: every size meets Fitts ≥ 32×32 (sm = 32, md = 40, lg = 48).
- The icon swap respects
prefers-reduced-motion: Motion's spring transition collapses to an instant fade. data-statereflects the active state ("on" | "off") for CSS and tests.- Visual-equivalent guarantee: the icon itself communicates state, so users who can't hear the audio cue still know whether sound is enabled — wiki
a11y-visual-equivalent.
Credits
- Extracted from:
terminal-dreams(src/components/cookbook/SoundToggle.tsx). The original was a project-specific button hard-coded to the cookbook'suseSoundinstance with no controlled API, no size variants, and no styling hook. craft-bits generalizes it into a CVA-driven primitive with three variants, three sizes, Radix-style controlled / uncontrolled APIs, and a hook-agnosticenabledprop that pairs cleanly with any audio gate.