SoundToggle
Opt-in interface sound: a `sound` controller that synthesises eleven short cues (hover, press, toggle-on, toggle-off, open, close, select, success, error, tick, copy) with the Web Audio API, a SoundToggle switch that keeps the preference and volume, and a soundFeedback action that wires cues to any element — silent until the user switches it on, nothing on import, mount, navigation or scroll
Preview
<script lang="ts">
import { Button } from "fancy-ui-svelte";
import { Toggle } from "fancy-ui-svelte";
import { Switch } from "fancy-ui-svelte";
import { Slider } from "fancy-ui-svelte";
import { CopyButton } from "fancy-ui-svelte";
import { SoundToggle, sound, soundFeedback, type SoundCue } from "fancy-ui-svelte";
// One row per cue, in the same order SOUND_CUES defines them. "Use"/"Avoid"
// stay short enough to scan a grid of eleven of these at once.
const CUES: { cue: SoundCue; label: string; use: string; avoid: string }[] = [
{
cue: "hover",
label: "Hover",
use: "Dense pointer UI: menus, docks, toolbars.",
avoid: "Lists and anything that scrolls — it's rate-limited and silent on touch.",
},
{
cue: "press",
label: "Press",
use: "Buttons and primary actions.",
avoid: "Pairing with a toggle cue on the same control.",
},
{
cue: "toggle-on",
label: "Toggle on",
use: "Switches, checkboxes, pressed toggles turning on.",
avoid: "Navigation.",
},
{
cue: "toggle-off",
label: "Toggle off",
use: "Switches, checkboxes, pressed toggles turning off.",
avoid: "Navigation.",
},
{
cue: "open",
label: "Open",
use: "Menus, dialogs, popovers opening.",
avoid: "Tooltips and hover cards.",
},
{
cue: "close",
label: "Close",
use: "Menus, dialogs, popovers closing.",
avoid: "Tooltips and hover cards.",
},
{
cue: "select",
label: "Select",
use: "Committing a choice in a list or menu.",
avoid: "Each arrow-key step — that's tick.",
},
{
cue: "success",
label: "Success",
use: "The outcome of an async action.",
avoid: "Per-keystroke validation.",
},
{
cue: "error",
label: "Error",
use: "The outcome of an async action.",
avoid: "Per-keystroke validation.",
},
{
cue: "tick",
label: "Tick",
use: "Steppers, sliders, scroll-snap — the only cue meant to repeat quickly.",
avoid: "Anything louder than near-silent; it fires often.",
},
{
cue: "copy",
label: "Copy",
use: "Clipboard confirmations, once per copy.",
avoid: "Re-firing for the same copy without a new user action.",
},
];
// Copy by engine/enabled state. Order matters: an unsupported browser wins
// over every other state (the toggle itself is forced disabled for the
// same reason), then the plain off/on/suspended cases.
const statusText = $derived.by(() => {
const status = sound.status;
if (status.engine === "unsupported") return "This browser has no Web Audio support.";
if (!sound.enabled) return "Sound is off. Switch it on to audition the cues.";
if (status.engine === "suspended" || status.engine === "blocked")
return "The browser paused audio. Any Play button resumes it.";
return `Sound on · ${Math.round(sound.volume * 100)}%`;
});
function toggleChangeCue(event: Event) {
const checked = (event.currentTarget as HTMLInputElement).checked;
return checked ? "toggle-on" : "toggle-off";
}
// The click reaches this wrapper after the Toggle's own handler ran but
// before Svelte has flushed the DOM, so aria-pressed still shows the OLD
// state: "false" means it is turning on.
function pressedToggleCue(event: Event) {
const button = (event.currentTarget as HTMLElement).querySelector("[aria-pressed]");
return button?.getAttribute("aria-pressed") === "true" ? "toggle-off" : "toggle-on";
}
</script>
<div data-sound-lab class="w-full max-w-3xl text-left">
<!-- Control bar. No aria-live: this example is mounted twice on the page
(the docs preview and the examples list), and a live region would
announce the same state change twice. -->
<div class="flex flex-wrap items-center gap-4">
<SoundToggle showLabel />
<p class="text-muted-foreground flex items-center gap-2 text-sm">
<span>{statusText}</span>
<span class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{sound.status.engine}</span>
</p>
<div class="flex w-full flex-col gap-1 sm:ml-auto sm:w-48">
<div class="flex items-center justify-between text-xs">
<span class="text-foreground font-medium">Volume</span>
<span class="text-muted-foreground font-mono">{Math.round(sound.volume * 100)}%</span>
</div>
<Slider
value={sound.volume * 100}
min={0}
max={100}
step={5}
label="Volume"
disabled={!sound.enabled}
onValueChange={(v) => sound.setVolume(v / 100)}
/>
</div>
</div>
<!-- Cue grid -->
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each CUES as c (c.cue)}
<!-- A cue is a sound; data-played is the same information for anyone
who cannot hear it — the last cue played keeps a visible ring
until another replaces it. -->
<div
class="ft-sound-lab-card border-border bg-card rounded-lg border p-3"
data-played={sound.status.lastCue === c.cue ? "true" : undefined}
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{c.label}</span>
<Button
size="sm"
variant="outline"
label={`Play ${c.cue}`}
disabled={!sound.enabled}
onclick={() => sound.play(c.cue)}
>
Play
</Button>
</div>
<p class="text-muted-foreground mt-2 text-xs">
<span class="text-foreground font-medium">Use</span>
{c.use}
</p>
<p class="text-muted-foreground text-xs">
<span class="text-foreground font-medium">Avoid</span>
{c.avoid}
</p>
</div>
{/each}
</div>
<!-- Try it live: wired only through use:soundFeedback, never the `sound`
prop those belong to the component-integration examples. -->
<div class="mt-6">
<p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Try it live</p>
<div class="border-border bg-card mt-3 flex flex-wrap items-center gap-3 rounded-lg border p-4">
<!-- Hover is opt-in: the default map is press only. -->
<span use:soundFeedback={{ on: { pointerenter: "hover", click: "press" } }}>
<Button variant="outline" size="sm">Hover + press</Button>
</span>
<span use:soundFeedback={{ on: { click: pressedToggleCue } }}>
<Toggle label="Pin">📌</Toggle>
</span>
<span use:soundFeedback={{ on: { change: toggleChangeCue } }}>
<Switch label="Notify" />
</span>
<span use:soundFeedback={{ on: { click: "copy" } }}>
<CopyButton value="npm install fancy-ui-svelte" />
</span>
<span use:soundFeedback={{ on: { click: "success" } }}>
<Button variant="ghost" size="sm">Succeed</Button>
</span>
<span use:soundFeedback={{ on: { click: "error" } }}>
<Button variant="ghost" size="sm">Fail</Button>
</span>
</div>
</div>
</div>
<style>
.ft-sound-lab-card {
--ft-sound-lab-accent: var(
--ft-accent,
light-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))
);
}
.ft-sound-lab-card[data-played="true"] {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ft-sound-lab-accent) 40%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
.ft-sound-lab-card {
transition: box-shadow 150ms ease;
}
}
</style>Installation
Usage
<script lang="ts">
import { SoundToggle } from 'fancy-ui-svelte';
</script>
<SoundToggle />Examples
Sound Lab
Every cue, its volume, and when to reach for it. Nothing plays until you switch sound on.
<script lang="ts">
import { Button } from "$lib/fancy-ui/button";
import { Toggle } from "$lib/fancy-ui/toggle";
import { Switch } from "$lib/fancy-ui/switch";
import { Slider } from "$lib/fancy-ui/slider";
import { CopyButton } from "$lib/fancy-ui/copy-button";
import { SoundToggle, sound, soundFeedback, type SoundCue } from "$lib/fancy-ui/sound/index.js";
// One row per cue, in the same order SOUND_CUES defines them. "Use"/"Avoid"
// stay short enough to scan a grid of eleven of these at once.
const CUES: { cue: SoundCue; label: string; use: string; avoid: string }[] = [
{
cue: "hover",
label: "Hover",
use: "Dense pointer UI: menus, docks, toolbars.",
avoid: "Lists and anything that scrolls — it's rate-limited and silent on touch.",
},
{
cue: "press",
label: "Press",
use: "Buttons and primary actions.",
avoid: "Pairing with a toggle cue on the same control.",
},
{
cue: "toggle-on",
label: "Toggle on",
use: "Switches, checkboxes, pressed toggles turning on.",
avoid: "Navigation.",
},
{
cue: "toggle-off",
label: "Toggle off",
use: "Switches, checkboxes, pressed toggles turning off.",
avoid: "Navigation.",
},
{
cue: "open",
label: "Open",
use: "Menus, dialogs, popovers opening.",
avoid: "Tooltips and hover cards.",
},
{
cue: "close",
label: "Close",
use: "Menus, dialogs, popovers closing.",
avoid: "Tooltips and hover cards.",
},
{
cue: "select",
label: "Select",
use: "Committing a choice in a list or menu.",
avoid: "Each arrow-key step — that's tick.",
},
{
cue: "success",
label: "Success",
use: "The outcome of an async action.",
avoid: "Per-keystroke validation.",
},
{
cue: "error",
label: "Error",
use: "The outcome of an async action.",
avoid: "Per-keystroke validation.",
},
{
cue: "tick",
label: "Tick",
use: "Steppers, sliders, scroll-snap — the only cue meant to repeat quickly.",
avoid: "Anything louder than near-silent; it fires often.",
},
{
cue: "copy",
label: "Copy",
use: "Clipboard confirmations, once per copy.",
avoid: "Re-firing for the same copy without a new user action.",
},
];
// Copy by engine/enabled state. Order matters: an unsupported browser wins
// over every other state (the toggle itself is forced disabled for the
// same reason), then the plain off/on/suspended cases.
const statusText = $derived.by(() => {
const status = sound.status;
if (status.engine === "unsupported") return "This browser has no Web Audio support.";
if (!sound.enabled) return "Sound is off. Switch it on to audition the cues.";
if (status.engine === "suspended" || status.engine === "blocked")
return "The browser paused audio. Any Play button resumes it.";
return `Sound on · ${Math.round(sound.volume * 100)}%`;
});
function toggleChangeCue(event: Event) {
const checked = (event.currentTarget as HTMLInputElement).checked;
return checked ? "toggle-on" : "toggle-off";
}
// The click reaches this wrapper after the Toggle's own handler ran but
// before Svelte has flushed the DOM, so aria-pressed still shows the OLD
// state: "false" means it is turning on.
function pressedToggleCue(event: Event) {
const button = (event.currentTarget as HTMLElement).querySelector("[aria-pressed]");
return button?.getAttribute("aria-pressed") === "true" ? "toggle-off" : "toggle-on";
}
</script>
<div data-sound-lab class="w-full max-w-3xl text-left">
<!-- Control bar. No aria-live: this example is mounted twice on the page
(the docs preview and the examples list), and a live region would
announce the same state change twice. -->
<div class="flex flex-wrap items-center gap-4">
<SoundToggle showLabel />
<p class="text-muted-foreground flex items-center gap-2 text-sm">
<span>{statusText}</span>
<span class="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{sound.status.engine}</span>
</p>
<div class="flex w-full flex-col gap-1 sm:ml-auto sm:w-48">
<div class="flex items-center justify-between text-xs">
<span class="text-foreground font-medium">Volume</span>
<span class="text-muted-foreground font-mono">{Math.round(sound.volume * 100)}%</span>
</div>
<Slider
value={sound.volume * 100}
min={0}
max={100}
step={5}
label="Volume"
disabled={!sound.enabled}
onValueChange={(v) => sound.setVolume(v / 100)}
/>
</div>
</div>
<!-- Cue grid -->
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each CUES as c (c.cue)}
<!-- A cue is a sound; data-played is the same information for anyone
who cannot hear it — the last cue played keeps a visible ring
until another replaces it. -->
<div
class="ft-sound-lab-card border-border bg-card rounded-lg border p-3"
data-played={sound.status.lastCue === c.cue ? "true" : undefined}
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium">{c.label}</span>
<Button
size="sm"
variant="outline"
label={`Play ${c.cue}`}
disabled={!sound.enabled}
onclick={() => sound.play(c.cue)}
>
Play
</Button>
</div>
<p class="text-muted-foreground mt-2 text-xs">
<span class="text-foreground font-medium">Use</span>
{c.use}
</p>
<p class="text-muted-foreground text-xs">
<span class="text-foreground font-medium">Avoid</span>
{c.avoid}
</p>
</div>
{/each}
</div>
<!-- Try it live: wired only through use:soundFeedback, never the `sound`
prop those belong to the component-integration examples. -->
<div class="mt-6">
<p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Try it live</p>
<div class="border-border bg-card mt-3 flex flex-wrap items-center gap-3 rounded-lg border p-4">
<!-- Hover is opt-in: the default map is press only. -->
<span use:soundFeedback={{ on: { pointerenter: "hover", click: "press" } }}>
<Button variant="outline" size="sm">Hover + press</Button>
</span>
<span use:soundFeedback={{ on: { click: pressedToggleCue } }}>
<Toggle label="Pin">📌</Toggle>
</span>
<span use:soundFeedback={{ on: { change: toggleChangeCue } }}>
<Switch label="Notify" />
</span>
<span use:soundFeedback={{ on: { click: "copy" } }}>
<CopyButton value="npm install fancy-ui-svelte" />
</span>
<span use:soundFeedback={{ on: { click: "success" } }}>
<Button variant="ghost" size="sm">Succeed</Button>
</span>
<span use:soundFeedback={{ on: { click: "error" } }}>
<Button variant="ghost" size="sm">Fail</Button>
</span>
</div>
</div>
</div>
<style>
.ft-sound-lab-card {
--ft-sound-lab-accent: var(
--ft-accent,
light-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))
);
}
.ft-sound-lab-card[data-played="true"] {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ft-sound-lab-accent) 40%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
.ft-sound-lab-card {
transition: box-shadow 150ms ease;
}
}
</style>Programmatic Play
sound.play() from an async handler — a no-op while sound is off, so call sites never branch on it.
<script lang="ts">
import { Button } from "$lib/fancy-ui/button";
import { SoundToggle, sound } from "$lib/fancy-ui/sound/index.js";
let saving = $state(false);
let attempts = 0;
let outcome = $state<"idle" | "success" | "error">("idle");
async function save() {
saving = true;
outcome = "idle";
// The outcome cue plays after an await, so unlock here — inside the click
// that started the work. On a reload with sound already enabled there is
// no AudioContext yet, and the transient user activation that lets one be
// created may be gone by the time the promise settles.
if (sound.enabled) void sound.unlock();
await new Promise((resolve) => setTimeout(resolve, 600));
attempts += 1;
const ok = attempts % 2 === 1;
outcome = ok ? "success" : "error";
// sound.play() is a no-op while sound is off — this call site never
// branches on the preference itself, on or off.
sound.play(ok ? "success" : "error");
saving = false;
}
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<SoundToggle size="sm" showLabel label="Sound for programmatic play" />
<span class="text-muted-foreground text-sm">
Turn sound on to hear the outcome, or leave it off to see play() do nothing.
</span>
</div>
<div class="flex items-center gap-3">
<Button loading={saving} onclick={save}>
{saving ? "Saving…" : "Save changes"}
</Button>
<!-- The outcome is always shown here too — audio is a confirmation,
never the only carrier of the result. -->
{#if outcome !== "idle"}
<span class={outcome === "success" ? "text-foreground text-sm" : "text-destructive text-sm"}>
{outcome === "success" ? "Saved." : "Failed to save."}
</span>
{/if}
</div>
</div>The soundFeedback Action
use:soundFeedback on any element, with cues swapped at runtime through the action's update path.
<script lang="ts">
import { Switch } from "$lib/fancy-ui/switch";
import {
SoundToggle,
soundFeedback,
type SoundFeedbackOptions,
} from "$lib/fancy-ui/sound/index.js";
let hoverEnabled = $state(true);
// Rebuilding the whole options object on every change is what exercises
// the action's own `update()` path — swapping the event map at runtime,
// not just its resolved cue.
const feedbackOptions = $derived<SoundFeedbackOptions>(
hoverEnabled ? { on: { click: "press", pointerenter: "hover" } } : { on: { click: "press" } }
);
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<SoundToggle size="sm" showLabel label="Sound for the action demo" />
<span class="text-muted-foreground text-sm">Turn sound on, then try the controls below.</span>
</div>
<Switch bind:checked={hoverEnabled} label="Also play a hover cue" />
<div class="flex flex-wrap items-center gap-5">
<button
type="button"
use:soundFeedback={feedbackOptions}
class="border-border bg-background hover:bg-accent hover:text-accent-foreground h-9 rounded-md border px-3 text-sm transition-colors"
>
Plain button
</button>
<button
type="button"
use:soundFeedback={feedbackOptions}
class="text-foreground text-sm underline underline-offset-4"
>
Link-styled button
</button>
<label class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Scrub</span>
<input
type="range"
min="0"
max="100"
use:soundFeedback={{ on: { input: "tick" } }}
class="h-1 w-32"
/>
</label>
</div>
</div>With Buttons
Button and CopyButton playing their own press and copy cues through the opt-in prop.
<script lang="ts">
import { Button } from "$lib/fancy-ui/button";
import { CopyButton } from "$lib/fancy-ui/copy-button";
import { SoundToggle } from "$lib/fancy-ui/sound/index.js";
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<SoundToggle size="sm" showLabel label="Sound for the button examples" />
<span class="text-muted-foreground text-sm">Turn sound on, then try the buttons below.</span>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button sound>Save changes</Button>
<Button sound variant="outline">Continue</Button>
<Button sound variant="destructive">Delete</Button>
<CopyButton sound value="npm install fancy-ui-svelte" />
</div>
</div>With Form Controls
Checkbox, Switch and RadioGroup — toggle-on, toggle-off and select.
<script lang="ts">
import { Checkbox } from "$lib/fancy-ui/checkbox";
import { Switch } from "$lib/fancy-ui/switch";
import { RadioGroup, RadioGroupItem } from "$lib/fancy-ui/radio-group";
import { SoundToggle } from "$lib/fancy-ui/sound/index.js";
let agreed = $state(false);
let notifications = $state(true);
let plan = $state("pro");
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<SoundToggle size="sm" showLabel label="Sound for the form controls" />
<span class="text-muted-foreground text-sm">
Turn sound on, then check the box, flip the switch, or pick a plan below.
</span>
</div>
<div class="flex flex-col gap-4">
<Checkbox sound bind:checked={agreed}>I agree to the terms</Checkbox>
<Switch sound bind:checked={notifications} label="Enable notifications" />
<RadioGroup sound bind:value={plan} label="Plan" orientation="horizontal">
<RadioGroupItem value="free" label="Free" />
<RadioGroupItem value="pro" label="Pro" />
<RadioGroupItem value="team" label="Team" />
</RadioGroup>
</div>
</div>With Menus
Select and DropdownMenu — open, close and select, one cue per interaction.
<script lang="ts">
import { Select } from "$lib/fancy-ui/select";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "$lib/fancy-ui/dropdown-menu";
import { SoundToggle } from "$lib/fancy-ui/sound/index.js";
const frameworks = [
{ value: "svelte", label: "Svelte 5" },
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
];
let framework = $state("svelte");
let lastAction = $state("");
</script>
<div class="flex flex-col gap-4">
<SoundToggle size="sm" showLabel label="Sound for the menus" />
<div class="flex flex-wrap items-center gap-4">
<Select
options={frameworks}
bind:value={framework}
label="Framework"
placeholder="Choose a framework"
class="w-[220px]"
sound
/>
<DropdownMenu sound>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => (lastAction = "Renamed")}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={() => (lastAction = "Duplicated")}>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => (lastAction = "Deleted")}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<!-- The cue is never the only carrier: the chosen action is also written out. -->
<p class="text-muted-foreground text-sm">
{lastAction ? `${lastAction} · framework: ${framework}` : `Framework: ${framework}`}
</p>
</div>Props
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Height of the control — md matches the other header-style triggers |
variant | "outline" | "ghost" | "outline" | Outline keeps a resting border; ghost shows one only on hover |
showLabel | boolean | false | Renders the label and the On/Off word beside the icon; icon-only otherwise, with the label as the accessible name |
label | string | "Sound" | Accessible name of the switch. Stays constant — the on/off state is announced through aria-checked |
labelOn | string | "On" | Visible state word when showLabel is set |
labelOff | string | "Off" | Visible state word when showLabel is set |
disabled | boolean | false | Disables the control. A browser with no Web Audio also disables it, but only while sound is off, so a stored on preference can always be undone |
onEnabledChange | (enabled: boolean) => void | - | Called after the preference flips, with the new value |
class | string | - | Additional CSS classes for the button |
ref | HTMLButtonElement | null | null | Bindable reference to the button |
sound.play(cue, options?) | (cue: SoundCue, options?: SoundPlayOptions) => void | - | Plays one cue. A no-op while sound is off or unsupported, so call sites never branch; options carry volume, pitch and playbackRate |
sound.enable() / disable() / toggle() | () => void | boolean | - | Flip the preference. Enabling creates and resumes the audio context inside the calling user gesture |
sound.setVolume(v) | (v: number) => void | - | Master volume, 0 to 1, persisted with the preference |
sound.enabled / volume / status | boolean / number / SoundStatus | - | Reactive preference and engine state (supported, engine, storage, lastCue, lastError) for your own UI |
sound.subscribe(run) | (run: (prefs: SoundPreferences) => void) => () => void | - | Store contract for non-rune consumers; sound.enabled is the idiomatic path |
use:soundFeedback | SoundFeedbackOptions | - | Action mapping element events to cues — defaults to click → press; add pointerenter → hover to opt in. Rebinds on update and unwires on destroy |
sound (on Button, CopyButton, Checkbox, Switch, RadioGroup, Select, DropdownMenu) | boolean | false | Per-instance opt-in: the component plays its own matching cue (press, copy/error, toggle-on/off, select, open/close) once the user has enabled sound |
Links
Related components
Button
The foundational push-button: six variants, three sizes, a loading state, and a polymorphic href/anchor mode
ButtonGroup
Joins a row of adjacent actions into one seamless control — one border, one divider, no doubled edges
CopyButton
Button preset wired to the clipboard, swapping its icon and label to a success skin for a moment after a successful copy