Skip to content

Sound

Add interface sounds for actions and feedback. Audio loads only when sound is enabled and unmuted.

Youj components can play a short, quiet sound as their motion starts, so a press, a toggle or a new toast can be heard as well as seen. Press a key below to hear each sound in the default pack.

The Beacon pack · the default · 15 KB

Make your own in the Sound builder

Add sound to actions and feedback such as presses, selections, outcomes, toasts and dialogs. Components emit a cue, an event that @youj/sound can play. Beacon is the pack to start with, and five more built-in packs (Halo, Rill, Glyph, Zephyr and Glass) are one prop away.

The sounds are rendered offline and checked for loudness and harshness. Audio downloads only when sound is enabled and unmuted. Without a sound prop, cues play nothing and no sound code is downloaded.

Installation

npm i @youj/sound

Or npx @youj/cli add sound, which installs the package in an app on the packages and copies the source in an app on the registry.

Usage

Give YoujProvider a pack with its sound option:

app/providers.tsx
'use client'

import type { ReactNode } from 'react'
import { YoujProvider } from '@youj/core'
import { beacon } from '@youj/sound'

export function Providers({ children }: { children: ReactNode }) {
  return <YoujProvider sound={beacon}>{children}</YoujProvider>
}

A pack is an object of functions, so in the Next.js App Router it must reach the provider from a Client Component: that's why the file starts with 'use client', and the root layout (a Server Component) renders <Providers> instead of passing sound={beacon} itself. A theme such as theme={basalt} is plain data and can stay on the layout's provider. npx @youj/cli add sound writes this file and wires the layout for you. Vite and Pages Router apps need no directive.

That's all the components need. Button, Switch, Checkbox, Toggle, Tabs, Segmented Control, Dialog, Sheet, Toaster and the copy buttons play their cue as their motion starts. To silence one, pass sound={false}, as in <Switch sound={false} />. To silence part of the page, nest a <YoujProvider sound={false}> around it.

Sound builder

Preview interface sounds, choose a sound for each cue and export your pack as code.

Your files

Add a WAV, MP3, OGG or M4A file to a cue, or drop it onto the cue. The builder decodes it in your browser without uploading it. It mixes the audio to mono, trims silence, adjusts loudness and fades the tail.

Loudness uses a K-weighted meter over the loudest 100 ms, with a peak limit below −1 dBTP. You’ll see warnings for sounds longer than 400 ms, clipping, or files that cannot match the pack’s loudness without clipping.

Your picks among the built-in sounds are in this page's link, so you can share them or come back to them. Your files stay in the tab: download the .zip to keep them.

Use the export

The export is lib/sounds.ts, a definePack that names each built-in pick by its candidate (candidates.tap.halo) and each of your files by the path it's served from (/sounds/tap.wav). The .zip adds your files as WAV, which every browser decodes, under public/sounds/, and a README. Copy them into your app and give the pack to the provider:

import { YoujProvider } from '@youj/core'
import { sounds } from '@/lib/sounds'

export function Providers({ children }: { children: React.ReactNode }) {
  return <YoujProvider sound={sounds}>{children}</YoujProvider>
}

Only the sounds you changed load, each in a chunk of its own, and only once sound is on. The cues you left alone play the Beacon pack.

How the candidates are made

Each cue has a candidate from each built-in pack, the default first. Beacon, the default, is pure, dry sine pings with a natural decay. Halo is soft chord swells with a slow shimmer. Rill is round droplets that rise and fall. Glyph is square pulses and quick arpeggios. Zephyr is a tiny blown pipe in a quiet room. Glass is a glass rod or bead, struck hard, in a small bright room. Glass's error is Classic, the first palette's two falling knocks. None is a sample: every sound is rendered by code in packages/sound/scripts, from seeded generators, so a render is the same on every machine.

The candidates of a cue are matched in loudness, stay under −1 dBTP after encoding, start and end at silence, and keep their energy out of the 2–5 kHz band where interface sounds turn shrill. The pitched sounds share the notes of E minor pentatonic, so any two that overlap agree.

Examples

Let people mute it

Offering sound is the app's choice, and muting it is the user's. useSound() (from @youj/core) reads and sets the mute from anywhere under the provider.

To start muted, so sound is there when someone turns it on, pass defaultMuted in the provider's sound object: <YoujProvider sound={{ pack: beacon, defaultMuted: true }}>. Without it, sound is on as soon as a pack is installed. The provider remembers the choice: once someone mutes or unmutes, their choice is stored in the browser and wins over defaultMuted on their next visit.

To keep the choice yourself instead, such as in a user's settings on your server, pass it as muted in the sound object and save it from onMutedChange:

'use client'

import { useState, type ReactNode } from 'react'
import { YoujProvider, useSound } from '@youj/core'
import { beacon } from '@youj/sound'
import { Switch } from '@youj/react'

export function Providers({ children }: { children: ReactNode }) {
  const [muted, setMuted] = useState(false) // load it from your settings
  return (
    <YoujProvider sound={{ pack: beacon, muted, onMutedChange: setMuted }}>
      {children}
    </YoujProvider>
  )
}

export function SoundSetting() {
  const sound = useSound()
  return <Switch aria-label="Interface sounds" checked={!sound.muted} onCheckedChange={(on) => sound.setMuted(!on)} />
}

Reference

Accessibilityannouncements

Accessibility

Sound never carries information on its own. Every cue doubles a visible change, such as the thumb moving, the check drawing or the dialog arriving, and, where there is one, an announcement, such as a button's outcome or a toast. Someone who can't hear it, or has muted it, misses nothing. Keep the same rule for your own sounds: play one only alongside a state change that is visible and announced.