Sounds and haptics

24 UI sounds synthesized in the browser the moment they play: no audio files, nothing to host, one 7 KB module. Each sound is a small recipe of two or three sine partials on a C major pentatonic, a soft attack and a filtered click, mixed at −14 dBFS. Sounds are off until the visitor turns them on, remembered per browser, and never play before a user gesture. Motion and sound are separate preferences: prefers-reduced-motion does not change them.

Sounds are off in this browser. The play buttons below work either way; components and data-sound only play when this is on.

Install

npx shadcn@latest add https://goodui.dev/r/sounds.json

It lands in lib/goodui-sounds.ts. Components with a sound prop list it as a dependency, so it comes along with them. The module is loaded lazily and only when a component has sound on, so an app that never uses it ships none of it.

Every sound

Click a card to hear it. The waveform is the sound rendered offline, normalized so quiet ones stay readable.

Components

Pass sound to play the component's default, or a sound name to pick another. Everything is off unless you pass it.

  • button: tap on click
  • stateful-button: tap, then success or error with the result
  • switch: toggle-on / toggle-off
  • checkbox: pop when checked, tick when unchecked
  • tabs: tick on select
  • rating: a tick per star under the pointer, pitched up along the row, pop on pick
  • number-input: tick per step, higher for +, lower for −
  • dialog: open / close
  • toast-stack (Toaster): success, error, warning or notify per new toast, again when a loading toast resolves
  • confetti: celebrate with the burst (fireConfetti({ sound: true }) or <Confetti sound />)
  • hold-to-confirm: tick when the hold starts, confirm when it completes
  • copy-button: pop after a copy, error when it fails
<Button sound onClick={save}>Save</Button>
<Switch sound label="SMS reminders" />
<Rating sound defaultValue={4} />
<Dialog sound title="Cancel this booking?" onConfirm={cancel} trigger={<Button variant="outline">Cancel</Button>} />
<Toaster sound />                   {/* success, error, warning or notify per toast */}
<HoldToConfirm sound="unlock" onConfirm={deleteAccount}>Delete account</HoldToConfirm>

Give people the switch

Mount SoundProvider once and put a toggle in your settings or header. The visitor's choice is saved in localStorage and wins over the app default.

app/layout.tsx
import { SoundProvider } from "@/lib/goodui-sounds"

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <SoundProvider>{children}</SoundProvider>   {/* enabled: app default, persist, volume, attributes */}
      </body>
    </html>
  )
}
components/sound-toggle.tsx
"use client"
import { useSound } from "@/lib/goodui-sounds"

export function SoundToggle() {
  const { enabled, toggle, play } = useSound()
  return (
    <button type="button" role="switch" aria-checked={enabled} onClick={() => { toggle(); play(enabled ? "toggle-off" : "toggle-on", { force: true }) }}>
      Sounds {enabled ? "on" : "off"}
    </button>
  )
}

Any element

With the provider mounted, any element can play a sound without code. data-sound plays on click (toggle reads the checked state after the click), data-sound-hover when the pointer enters.

<button data-sound="tap">Book</button>
<input type="checkbox" data-sound="toggle" />
<a href="/pricing" data-sound-hover>Pricing</a>   {/* "hover", very subtle */}

API

import { play, useSound, setSoundEnabled, isSoundEnabled, setSoundVolume, SOUND_NAMES, type SoundName } from "@/lib/goodui-sounds"

play("success")                          // false when sounds are off, before the first gesture, or without Web Audio
play("tick", { rate: 1.1, volume: 0.6 }) // rate: pitch and speed, volume: 0..1 on top of the -14 dBFS bus
play("notify", { force: true })          // previews and settings: ignores the toggle
setSoundEnabled(true)                    // saved in localStorage (unless <SoundProvider persist={false} />)
setSoundVolume(0.5)

const { enabled, toggle, setEnabled, play } = useSound()   // reads "off" during SSR

Design your own

A recipe is a list of voices: oscillator partials or filtered noise, each with a start, a length, a level and an optional glide. tone() gives a note with its octave partial, noise() a click or a whoosh. Keep it short (under 350 ms), quiet (levels under 0.5) and on the scale, and it will sit next to the built-ins.

import { defineSound, tone, noise, play } from "@/lib/goodui-sounds"

// A cash-register "cha-ching": a click, then two notes a fifth apart with a bright octave.
defineSound("cha-ching", [
  noise(0.02, { gain: 0.2, filter: 3000 }),
  ...tone(1046.5, 0.12, { at: 0.03, gain: 0.4, shine: 0.3 }),   // C6
  ...tone(1568, 0.26, { at: 0.11, gain: 0.42, shine: 0.3 }),    // G6
])

play("cha-ching")                 // or <button data-sound="cha-ching">
defineSound("tap", [...])         // built-ins can be replaced the same way

Voice fields: wave (sine, triangle, square, sawtooth or noise), freq and to in Hz, glide, at, dur and attack in seconds, gain 0..1, filter / filterTo / q for the lowpass (oscillators) or bandpass (noise), detune in cents.

Testing

renderSound(name) renders a recipe through an OfflineAudioContext and returns the buffer, so a test can assert a sound is neither silent nor clipping without playing anything.

const buffer = await renderSound("success")
const data = buffer.getChannelData(0)
const peak = Math.max(...data.map(Math.abs))   // the built-ins stay under 0.3 at volume 1

goodui Native: haptics

The same names are haptic patterns in goodui Native (foundation/haptics.ts), built from expo-haptics impact, notification and selection taps with timing. cue(name) from foundation/sounds plays the pattern and the sound in one call, so a screen written once feels right on every platform. Sounds are off until the app calls setSoundsEnabled(true); on iOS and Android they play through a player the app registers with setSoundPlayer (expo-audio with its own short files), on Expo web they are synthesized like here.

  • tap: impact light
  • select, tick, count-tick: selection
  • toggle-on / toggle-off: impact light → medium / medium → light, 60 ms apart
  • success, warning, error: notification of the same type
  • notify: two light impacts, 90 ms apart
  • send / receive: light → rigid / soft → light
  • delete: impact heavy
  • undo: light → medium
  • pop: impact rigid
  • whoosh / slide: three / two selections
  • confirm: impact medium, then notification success
  • open / close: soft → light / light → soft
  • drag-start / drop: impact medium / heavy → light
  • celebrate: light, light, medium, then notification success
  • unlock: rigid, rigid, then notification success
  • hover: nothing (no hover on touch)
import { cue, setSoundsEnabled } from "@/components/goodui-native/foundation"

setSoundsEnabled(settings.sounds)        // from your settings screen
cue("confirm")                           // haptic pattern + sound, one vocabulary