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

Usage

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

  1. Pairs naturally with useSound. The companion useSound hook exposes { enabled, setEnabled } with localStorage persistence and a reduced-motion guard. Wire those into SoundToggle and the same value gates both the icon and the actual audio output — wiki a11y-toggle-setting ("every sound has a global off switch").
  2. 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 d string produces visible knot-flips. We render one motion.path and swap its d with a key change — Motion's per-key initial/animate scales the new glyph in from 0.6 and fades it from 0. Same SPRINGS.snap as the button's tap response, so the icon flip and the press share a tempo.
  3. 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.
  4. Controlled + uncontrolled. enabled + onEnabledChange for controlled, defaultEnabled + onEnabledChange for uncontrolled. Same convention as @radix-ui/react-toggle.
  5. Action-shaped labels. aria-label describes 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

PropTypeDefaultDescription
enabledbooleanControlled enabled state. Pair with onEnabledChange.
defaultEnabledbooleantrueUncontrolled initial enabled state.
onEnabledChange(next: boolean) => voidFired 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).
disabledbooleanfalseDisables the button and drops the tap gesture.
classNamestringMerged onto the rendered <button> via cn().
...restHTMLMotionProps<'button'>Any other motion.button prop.

Accessibility

  • Renders a real <button type="button"> with aria-pressed reflecting the current state — screen readers announce both the action ("Mute" / "Unmute") and the toggle state.
  • Keyboard activation: Enter and Space toggle 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-state reflects 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's useSound instance 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-agnostic enabled prop that pairs cleanly with any audio gate.