音效与触感

24 种界面音效,在浏览器里于播放的那一刻合成:没有音频文件,无需托管,只有一个 7 KB 的模块。每个音效都是一份小配方:C 大调五声音阶上的两三个正弦分音、柔和的起音和一声经过滤波的点击,以 −14 dBFS 混合。音效默认关闭,直到访客开启,按浏览器记住,并且绝不会在用户手势之前播放。动效和音效是两个独立的偏好:prefers-reduced-motion 不会影响它们。

此浏览器中的声音已关闭。下方的播放按钮始终可用;组件和 data-sound 只在开启时发声。

安装

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

它落在 lib/goodui-sounds.ts 里。带 sound prop 的组件把它列为依赖,所以会随组件一起安装。模块是懒加载的,而且只在某个组件开启了 sound 时才加载,所以从不使用它的应用不会打包任何相关代码。

全部音效

点击卡片试听。波形是离线渲染的音效,经过归一化,所以轻的音效也看得清。

组件

传 sound 播放组件的默认音效,或传一个音效名选用别的。不传就全部关闭。

  • button: 点击时 tap
  • stateful-button: 先 tap,然后随结果播放 success 或 error
  • switch: toggle-on / toggle-off
  • checkbox: 勾选时 pop,取消时 tick
  • tabs: 选中时 tick
  • rating: 指针下每颗星一声 tick,沿行逐渐升高,选定时 pop
  • number-input: 每步一声 tick,+ 更高,− 更低
  • dialog: open / close
  • toast-stack (Toaster): 每条新 toast 播放 success、error、warning 或 notify,加载中的 toast 完成时再播放一次
  • confetti: 随爆发播放 celebrate(fireConfetti({ sound: true }) 或 <Confetti sound />)
  • hold-to-confirm: 开始长按时 tick,完成时 confirm
  • copy-button: 复制后 pop,失败时 error
<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>

把开关交给用户

挂载一次 SoundProvider,然后在设置或页头放一个开关。访客的选择保存在 localStorage 里,优先于应用默认值。

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>
  )
}

任意元素

挂载 provider 后,任何元素都能不写代码播放音效。data-sound 在点击时播放(toggle 会在点击后读取选中状态),data-sound-hover 在指针进入时播放。

<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

设计你自己的音效

配方是一组声部:振荡器分音或经滤波的噪声,每个都有起始时间、时长、音量和可选的滑音。tone() 给出一个音符及其八度分音,noise() 给出一声点击或一阵呼啸。保持短(350 ms 以内)、轻(音量低于 0.5)、在音阶上,它就能和内置音效和谐共处。

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

声部字段:wave(sine、triangle、square、sawtooth 或 noise),freq 和 to 单位 Hz,glide、at、dur 和 attack 单位秒,gain 0..1,filter / filterTo / q 用于低通(振荡器)或带通(噪声),detune 单位音分。

测试

renderSound(name) 通过 OfflineAudioContext 渲染配方并返回缓冲区,所以测试可以断言音效既不静音也不削波,而无需真正播放。

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:触感

同样的名字在 goodui Native 里是触感模式(foundation/haptics.ts),由 expo-haptics 的 impact、notification 和 selection 触感加上时序构成。foundation/sounds 里的 cue(name) 一次调用同时播放触感和音效,所以写一次的界面在每个平台上手感都对。音效默认关闭,直到应用调用 setSoundsEnabled(true);在 iOS 和 Android 上通过应用用 setSoundPlayer 注册的播放器播放(expo-audio 配自己的短音频文件),在 Expo web 上则像这里一样合成。

  • tap: impact light
  • select, tick, count-tick: selection
  • toggle-on / toggle-off: impact light → medium / medium → light,间隔 60 ms
  • success, warning, error: 同类型的 notification
  • notify: 两次 light impact,间隔 90 ms
  • send / receive: light → rigid / soft → light
  • delete: impact heavy
  • undo: light → medium
  • pop: impact rigid
  • whoosh / slide: 三次 / 两次 selection
  • confirm: impact medium,然后 notification success
  • open / close: soft → light / light → soft
  • drag-start / drop: impact medium / heavy → light
  • celebrate: light、light、medium,然后 notification success
  • unlock: rigid、rigid,然后 notification success
  • hover: 无(触屏没有悬停)
import { cue, setSoundsEnabled } from "@/components/goodui-native/foundation"

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