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.
data-sound only play when this is on.Install
npx shadcn@latest add https://goodui.dev/r/sounds.jsonIt 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 clickstateful-button: tap, then success or error with the resultswitch: toggle-on / toggle-offcheckbox: pop when checked, tick when uncheckedtabs: tick on selectrating: a tick per star under the pointer, pitched up along the row, pop on picknumber-input: tick per step, higher for +, lower for −dialog: open / closetoast-stack (Toaster): success, error, warning or notify per new toast, again when a loading toast resolvesconfetti: celebrate with the burst (fireConfetti({ sound: true }) or <Confetti sound />)hold-to-confirm: tick when the hold starts, confirm when it completescopy-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.
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>
)
}"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 SSRDesign 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 wayVoice 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 1goodui 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 lightselect, tick, count-tick: selectiontoggle-on / toggle-off: impact light → medium / medium → light, 60 ms apartsuccess, warning, error: notification of the same typenotify: two light impacts, 90 ms apartsend / receive: light → rigid / soft → lightdelete: impact heavyundo: light → mediumpop: impact rigidwhoosh / slide: three / two selectionsconfirm: impact medium, then notification successopen / close: soft → light / light → softdrag-start / drop: impact medium / heavy → lightcelebrate: light, light, medium, then notification successunlock: rigid, rigid, then notification successhover: 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