# Sound Is Never the Only Channel

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

> MUST: Give every audio cue a visual equivalent — sound is a reinforcement channel and never carries feedback alone (WCAG 1.3.3). A Submit whose only success signal is a chime does nothing observable on a muted tab, and nothing at all for a deaf user. Removing the sound must cost nothing.

Every audio cue needs a visual equivalent — sound supplements feedback, it never carries it

Cherry-picked from Raphael Salaja's sounds-on-the-web skill (rule `a11y-visual-equivalent`). The failure is easy to reproduce and easy to miss in development: a Submit button whose only success signal is a chime. On a muted tab — which is the default state of most tabs most of the time — pressing Submit produces no observable change at all, and the user presses it again. Deaf and hard-of-hearing users get the same nothing, permanently. This is WCAG 1.3.3 Sensory Characteristics: instructions and feedback must not depend on a single sensory characteristic. It is the same principle as "Redundant Status Cues", which is scoped to colour and WCAG 1.4.1 — one channel, one criterion, identical shape. Sound is simply the channel that is switched off far more often than colour is. The rule is not "no sound"; sound is a genuinely good second channel when the user is not looking at the screen. The rule is that removing it must cost nothing.

## Bad — do not do this

`interactions-sound-not-sole-channel-bad`

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

/**
 * The only signal that Submit worked is a sound. On a muted tab — the default
 * state of most tabs — pressing Submit produces no observable change at all.
 * WCAG 1.3.3: feedback must not depend on a single sensory characteristic.
 */
export function SoundNotSoleChannelBad() {
  const [soundEnabled, setSoundEnabled] = useState(false);
  const [blips, setBlips] = useState(0);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (soundEnabled) playBlip();
    setBlips((n) => n + 1);
    // ...and that is the entire success path. Nothing visual happens.
  };

  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={soundEnabled}
            onChange={(e) => setSoundEnabled(e.target.checked)}
            className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          Enable sound for this demo (off by default — nothing autoplays)
        </label>

        {/* ---- the product UI ---- */}
        <form onSubmit={handleSubmit} className="rounded-md border border-border bg-muted p-4">
          <label htmlFor="sound-bad-email" className="block text-xs text-muted-foreground mb-1">
            Email
          </label>
          <input
            id="sound-bad-email"
            type="email"
            autoComplete="email"
            defaultValue="ada@example.com"
            className="mb-3 w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          <button
            type="submit"
            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"
          >
            Submit
          </button>
        </form>
        {/* ---- end product UI: it never changes, no matter how many times you submit ---- */}

        <p className="mt-3 text-xs text-muted-foreground">
          Press Submit with the tab muted (the default). The form does not change. No label, no state, no
          message — nothing tells you it worked. Deaf and hard-of-hearing users get this experience
          permanently.
        </p>

        <div className="mt-3 rounded-md border border-dashed border-border p-2">
          <p className="text-xs text-muted-foreground">
            Audio monitor — instrumentation for this demo, <em>not</em> part of the UI above:
          </p>
          <p className="mt-1 text-xs font-mono text-destructive" aria-live="polite">
            {blips === 0
              ? 'no submissions yet'
              : `${blips} success blip${blips > 1 ? 's' : ''} ${soundEnabled ? 'played' : 'suppressed (sound off)'} — and that was the only feedback`}
          </p>
        </div>
      </div>
      <p className="text-xs text-destructive mt-4">
        Sound is the sole success channel — with audio off, nothing observable happens
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-sound-not-sole-channel-good`

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

/**
 * The same blip, plus a visible state change and an aria-live announcement.
 * Sound is a bonus channel for users who have it on; removing it costs nothing.
 */
export function SoundNotSoleChannelGood() {
  const [soundEnabled, setSoundEnabled] = useState(false);
  const [status, setStatus] = useState<'idle' | 'sent'>('idle');
  const [blips, setBlips] = useState(0);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (soundEnabled) playBlip();
    setBlips((n) => n + 1);
    // The sound is the second channel. This is the first one.
    setStatus('sent');
  };

  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={soundEnabled}
            onChange={(e) => setSoundEnabled(e.target.checked)}
            className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          Enable sound for this demo (off by default — nothing autoplays)
        </label>

        {/* ---- the product UI ---- */}
        <form onSubmit={handleSubmit} className="rounded-md border border-border bg-muted p-4">
          <label htmlFor="sound-good-email" className="block text-xs text-muted-foreground mb-1">
            Email
          </label>
          <input
            id="sound-good-email"
            type="email"
            autoComplete="email"
            defaultValue="ada@example.com"
            onChange={() => setStatus('idle')}
            className="mb-3 w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          <button
            type="submit"
            data-status={status}
            className={`w-full rounded-md px-3 py-2 text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
              status === 'sent'
                ? 'bg-success/15 text-success ring-1 ring-success'
                : 'bg-primary text-primary-foreground'
            }`}
          >
            {status === 'sent' ? '✓ Sent' : 'Submit'}
          </button>

          {/* Announced to screen readers, visible to everyone, audible to whoever wants it. */}
          <p role="status" aria-live="polite" className="mt-2 min-h-4 text-xs text-success">
            {status === 'sent' ? 'Submitted. We sent a confirmation to ada@example.com.' : ''}
          </p>
        </form>
        {/* ---- end product UI ---- */}

        <p className="mt-3 text-xs text-muted-foreground">
          Mute the tab and press Submit: the button flips to &ldquo;Sent&rdquo;, the message appears, the
          live region announces it. Turn sound on and you also get a blip. Every channel is redundant with
          the others.
        </p>

        <div className="mt-3 rounded-md border border-dashed border-border p-2">
          <p className="text-xs text-muted-foreground">
            Audio monitor — instrumentation for this demo, <em>not</em> part of the UI above:
          </p>
          <p className="mt-1 text-xs font-mono text-success" aria-live="polite">
            {blips === 0
              ? 'no submissions yet'
              : `${blips} success blip${blips > 1 ? 's' : ''} ${soundEnabled ? 'played' : 'suppressed (sound off)'} — the visual feedback landed either way`}
          </p>
        </div>
      </div>
      <p className="text-xs text-success mt-4">
        Sound supplements a visible state change and a live region — muting it loses nothing
      </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.3.3 Sensory Characteristics](https://www.w3.org/WAI/WCAG22/Understanding/sensory-characteristics.html)
