# Sound Belongs to the User

**MUST** · **ID:** `interactions-sound-is-user-owned` · **Category:** interactions
**Source:** Custom
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-sound-is-user-owned

> MUST: Ship an explicit mute toggle in settings plus a volume control independent of system volume, and default the volume subtle (`0.3`) — never loud, never autoplaying. The OS mixer silences everything, not just you, so "turn your machine down" is not an off switch.

Ship an explicit mute toggle, an independent volume control, and a subtle default

Cherry-picked from Raphael Salaja's sounds-on-the-web skill, merging three of its rules (`a11y-toggle-setting`, `a11y-volume-control`, `impl-default-subtle`). A sound provider with no off switch treats audio as a property of the product rather than a property of the session, and the user's only recourse is the OS mixer — which silences everything, not just you. Three controls fix it. An explicit toggle, because "turn down my whole machine" is not an answer to "this app is noisy". An in-app volume independent of system volume, because the right level for a notification chime and the right level for a video are different numbers, and only the user knows what else is playing. And a subtle default: the upstream number is 0.3, chosen so that the first sound a user hears is never the loudest thing they have heard today. Defaulting to full volume converts a nice touch into an ambush, and an ambushed user mutes the tab permanently — which loses you the channel entirely.

## Bad — do not do this

`interactions-sound-is-user-owned-bad`

```tsx
import { useState } from 'react';
import { playBlip } from '@/components/demo-kit/playBlip';

// Hardcoded, full blast, no way to change it.
const DEFAULT_VOLUME = 1.0;

/**
 * A sound provider with no off switch and no volume. Audio is treated as a property
 * of the product rather than of the session, so the user's only recourse is the OS
 * mixer — which silences everything, not just this app.
 */
export function SoundIsUserOwnedBad() {
  const [demoAudioAllowed, setDemoAudioAllowed] = useState(false);
  const [plays, setPlays] = useState(0);

  const notify = () => {
    // No `if (soundEnabled)`. No `audio.volume = userVolume`. There is nothing to check.
    if (demoAudioAllowed) playBlip(DEFAULT_VOLUME);
    setPlays((n) => n + 1);
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        <label className="mb-4 flex items-center gap-2 text-xs text-muted-foreground">
          <input
            type="checkbox"
            checked={demoAudioAllowed}
            onChange={(e) => setDemoAudioAllowed(e.target.checked)}
            className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          Let this demo make sound (off by default — this switch belongs to the guide, not to the app below)
        </label>

        {/* ---- the product UI ---- */}
        <div className="rounded-md border border-border bg-muted p-4">
          <div className="mb-3 flex items-center justify-between">
            <p className="text-sm text-foreground">Notifications</p>
            <span className="text-xs text-muted-foreground">Settings</span>
          </div>
          <p className="mb-3 text-xs text-muted-foreground">
            Email digest, mentions, replies… and no sound preferences of any kind. Not a mute switch, not a
            volume. The chime is whatever we decided it is.
          </p>
          <button
            onClick={notify}
            className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Trigger a notification
          </button>
        </div>
        {/* ---- end product UI ---- */}

        <dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
          <div>
            <dt className="text-muted-foreground">volume</dt>
            <dd className="font-mono text-destructive">{DEFAULT_VOLUME.toFixed(1)} — hardcoded</dd>
          </div>
          <div>
            <dt className="text-muted-foreground">mute control</dt>
            <dd className="font-mono text-destructive">none</dd>
          </div>
        </dl>

        <div className="mt-3 rounded-md border border-dashed border-border p-2">
          <p className="text-xs text-muted-foreground">Audio monitor — instrumentation, not product UI:</p>
          <p className="mt-1 text-xs font-mono text-destructive" aria-live="polite">
            {plays === 0
              ? 'nothing played yet'
              : `${plays} chime${plays > 1 ? 's' : ''} at volume 1.0 ${demoAudioAllowed ? 'played' : 'suppressed by the guide, not by the app'} — the app offers no way to stop them`}
          </p>
        </div>
      </div>
      <p className="text-xs text-destructive mt-4">
        No mute, no volume, hardcoded 1.0 — the user&apos;s only escape is silencing their whole machine
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-sound-is-user-owned-good`

```tsx
import { useState } from 'react';
import { playBlip } from '@/components/demo-kit/playBlip';

// The upstream skill's own number: subtle, not loud.
const DEFAULT_VOLUME = 0.3;

/**
 * Three controls, all owned by the user: an explicit mute toggle, an in-app volume
 * independent of system volume, and a subtle default. Sound becomes a property of
 * the session rather than of the product.
 */
export function SoundIsUserOwnedGood() {
  const [soundEnabled, setSoundEnabled] = useState(false);
  const [volume, setVolume] = useState(DEFAULT_VOLUME);
  const [plays, setPlays] = useState(0);

  const notify = () => {
    if (soundEnabled) playBlip(volume);
    setPlays((n) => n + 1);
  };

  return (
    <div className="w-full max-w-sm">
      <div className="bg-card border border-border rounded-lg p-4">
        {/* ---- the product UI: the sound controls ARE the product here ---- */}
        <div className="rounded-md border border-border bg-muted p-4">
          <div className="mb-3 flex items-center justify-between">
            <p className="text-sm text-foreground">Notifications</p>
            <span className="text-xs text-muted-foreground">Settings</span>
          </div>

          <label className="mb-3 flex items-center justify-between gap-2 text-xs text-foreground">
            <span>Notification sounds</span>
            <input
              type="checkbox"
              checked={soundEnabled}
              onChange={(e) => setSoundEnabled(e.target.checked)}
              className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
            />
          </label>

          <label
            htmlFor="sound-volume"
            className="mb-1 flex items-center justify-between text-xs text-foreground"
          >
            <span>Volume</span>
            <span className="font-mono text-muted-foreground">{volume.toFixed(2)}</span>
          </label>
          <input
            id="sound-volume"
            type="range"
            min={0}
            max={1}
            step={0.05}
            value={volume}
            disabled={!soundEnabled}
            onChange={(e) => setVolume(Number(e.target.value))}
            className="mb-3 w-full accent-primary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
          />

          <button
            onClick={notify}
            className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Trigger a notification
          </button>
        </div>
        {/* ---- end product UI ---- */}

        <p className="mt-3 text-xs text-muted-foreground">
          Off by default, so nothing ambushes anyone. Turn it on and it starts at{' '}
          <span className="font-mono text-foreground">{DEFAULT_VOLUME}</span> — the level the upstream skill
          recommends, quiet enough that the first sound a user hears is never the loudest thing they have
          heard today. The slider is independent of system volume: only the user knows what else is playing.
        </p>

        <div className="mt-3 rounded-md border border-dashed border-border p-2">
          <p className="text-xs text-muted-foreground">Audio monitor — instrumentation, not product UI:</p>
          <p className="mt-1 text-xs font-mono text-success" aria-live="polite">
            {plays === 0
              ? 'nothing played yet'
              : soundEnabled
                ? `${plays} chime${plays > 1 ? 's' : ''} played at volume ${volume.toFixed(2)}`
                : `${plays} chime${plays > 1 ? 's' : ''} suppressed — the user turned sounds off, in the app`}
          </p>
        </div>
      </div>
      <p className="text-xs text-success mt-4">
        Mute toggle, independent volume, subtle 0.3 default — the user owns the channel
      </p>
    </div>
  );
}
```

## References

- [Raphael Salaja: sounds-on-the-web SKILL.md](https://github.com/raphaelsalaja/skill/blob/main/skills/sounds-on-the-web/SKILL.md)
- [WCAG 2.2 SC 1.4.2 Audio Control](https://www.w3.org/WAI/WCAG22/Understanding/audio-control.html)
