Documentation

Everything cuelume exports, and when to reach for it.

Ten exports and seven attributes. Most projects only ever needbind(). The rest is here for sounds at an exact moment, a second material, or a listener who wants it quieter.

Install

Install Cuelume, then import it wherever your interface runs.

$

Click the command to switch package manager. ESM-only, zero runtime dependencies, and safe to import during SSR.

Quick start

Mark up the elements that should make a sound, then wire them all at once. Nothing else is needed.

index.html
<button data-cuelume-tap>Save</button>
<input data-cuelume-type placeholder="Search">
<button data-cuelume-open>Open menu</button>
<a href="/settings" data-cuelume-navigate>Settings</a>
app.ts
import { bind } from "cuelume";

bind();

Attributes

Each attribute covers one behavior. Leave the value empty to use its default sound, or set it to any name from thecatalog.

AttributeFires onDefault
data-cuelume-tapFires onclickDefaulttap
data-cuelume-typeFires onkeydown (edits)Defaulttype
data-cuelume-selectFires onchange, else clickDefaultselect
data-cuelume-toggleFires onclickDefaulttoggle
data-cuelume-openFires onclickDefaultopen
data-cuelume-closeFires onclickDefaultclose
data-cuelume-navigateFires onclickDefaultnavigate

Add data-cuelume-emphasis="subtle" or"strong" to an element or any ancestor to set how much the action matters.data-cuelume-theme="bubble" sets the material the same way.

A marked control sounds as it goes down and lets go as it comes up. A mouse plays the press on pointerdown and the release on the click that follows. Touch and pen play both on lift, so a scroll that starts on a button stays silent. Enter or Space plays both at once. Only a theme with a release makes the let-go audible; press is the first.

bind()

bind(root?: ParentNode): () => void

Delegates every data-cuelume-* interaction under root, which defaults to the whole document. Call it once at startup.

It is idempotent, so calling it again is harmless, and it handles elements added later, so you do not need to re-bind after a render.

It returns a function that unbinds, for a component that unmounts or a module that hot reloads. A root stays bound until every caller that bound it has called theirs.

app.ts
import { bind } from "cuelume";

bind();                               // the whole document
bind(document.querySelector("#app")); // or one subtree

useEffect(() => bind(), []);          // React: bind() returns its cleanup

play()

play(name?: SoundName, options?: PlayOptions): void

Plays a sound immediately, for moments that are not a DOM interaction: a request that resolved, a copy that landed, a form that failed. Defaults to tap.

Options apply to this play only: volume,emphasis, the context a binding would detect (direction, key,input), a theme for this play, and a duration forcount. Unknown values are ignored.

app.ts
import { play } from "cuelume";

play("loading");
try {
  await exportReport();
  play("ready");                         // the file is there
} catch {
  play("error");
}

play("navigate", { direction: "back" }); // the whoosh falls
play("success", { emphasis: "strong" }); // a publish, not an autosave
play("success", { volume: 0.4 });        // quieter, just this once
play("count", { duration: 900 });        // a number rolling to its new value

Browsers block audio on a fresh visit until the listener interacts. Play navigate on client-side navigations after that first interaction.

press() / release()

press(name?: SoundName, options?: PlayOptions): void release(name?: SoundName): void

The two halves of play(), for a custom control that does not use bind(). Callpress as the gesture goes down andrelease with the same cue as it lifts. The release keeps its press's theme and emphasis. A second release, or one with no press waiting, plays nothing. play()plays both, the release 90 ms after the press.

key.ts
import { press, release } from "cuelume";

key.addEventListener("pointerdown", () => press("tap"));
key.addEventListener("pointerup", () => release("tap"));

unlock()

unlock(): Promise<boolean>

Starts audio now, from a user gesture, instead of on the first cue. Browsers let a page make sound only after someone has interacted with it, and some start audio only inside the gesture itself, so a cue played later from code can stay silent. Callunlock() in the click that turns sound on.

It resolves true once cues can sound andfalse while the browser still blocks audio or has no Web Audio, and it never rejects. Called before any interaction it resolves false and builds nothing: it cannot start audio the browser refuses.

settings.ts
import { setEnabled, unlock } from "cuelume";

soundToggle.addEventListener("click", async () => {
  setEnabled(true);
  const ready = await unlock(); // false while the browser still blocks audio
});

setTheme()

setTheme(theme: "default" | "mech" | "bubble" | "press"): void

Switches the material of future playback. All four themes carry the same fourteen cues at the same levels, so a switch changes how they sound, not what they mean. For one playful moment, passtheme to play() or setdata-cuelume-theme instead. Unknown names are ignored and the choice is not persisted.

press is the first theme that lets go: a tap clicks over a deep note that rings for about a second, then lifts with a lighter click. Taps less than a second apart walk that note up three steps and round again.

app.ts
import { play, setTheme } from "cuelume";

setTheme("mech");    // dry, machined parts
setTheme("bubble");  // knocks, drips, corks, and gulps
setTheme("press");   // a click over a deep note, and a lighter one letting go
setTheme("default"); // glass, wood, air, soft mallets

play("select", { theme: "bubble" }); // one playful moment

setVolume()

setVolume(volume: number): void

Sets the global level for future playback, clamped between 0 and 1. Non-finite values are ignored.

Your app owns the setting: cuelume applies it but never persists it, so wire it to whatever preference store you already have.

app.ts
import { setVolume } from "cuelume";

setVolume(0.7);
setVolume(Number(localStorage.getItem("ui-volume") ?? 1));

setEnabled()

setEnabled(enabled: boolean): void

Turns sound on or off. Off is heard at once: cues already ringing fade within a few milliseconds. Like the volume, the preference is not persisted.

app.ts
import { setEnabled } from "cuelume";

setEnabled(false); // silent at once; later plays are no-ops
setEnabled(true);

sounds

sounds: readonly SoundName[]

Every sound name, in catalog order. Useful for building a picker without hardcoding the list.

app.ts
import { sounds, play } from "cuelume";

sounds.forEach((name) => {
  // one button per cue
});

SoundName

type SoundName = "tap" | "type" | "select" | "toggle" | "open" | "close" | "success" | "error" | "navigate" | "warning" | "loading" | "ready" | "attention" | "count"

The union of the fourteen cue names. Import it as a type to keep your own sound maps honest.

app.ts
import { play, type SoundName } from "cuelume";

const forStatus: Record<"ok" | "warn" | "bad", SoundName> = {
  ok: "success",
  warn: "warning",
  bad: "error",
};

play(forStatus[status]);

Sound catalog

Fourteen cues, each with its own shape. Press one to hear it.

Which cue?

Pick by the job, not by the sound. Every row uses a cue that exists; emphasis and options do the rest. Never play one per streamed token, per tool call, or on hover: play one when the run ends. An animated number gets one count, never one per digit.

MomentCueHow
Primary button: save, send, submitCuetapHow—
Secondary or ghost buttonCuetapHowsubtle
Destructive confirm: delete, removeCuecloseHowstrong
Copy to clipboardCuesuccessHowsubtle
Like, star, bookmarkCuetoggleHow—
Undo / redoCuenavigateHowsubtle, direction "back" / "forward"
Keystroke, delete, space, returnCuetypeHowbindings set key
Autocomplete acceptedCueselectHow—
Field fails validationCueerrorHowsubtle
Menu, dropdown, or list optionCueselectHow—
Tab, segmented control, radioCueselectHowbindings set direction
Checkbox, switchCuetoggleHowbindings set direction
Slider or stepper stepCueselectHowsubtle, direction
Menu or popover opens / closesCueopen / closeHowsubtle
Dialog or drawer opens / closesCueopen / closeHow—
Accordion expands / collapsesCueopen / closeHowsubtle
Command paletteCueopenHowsubtle, or nothing
ToastCuethe cue for what it reportsHowsubtle
Route changeCuenavigateHow—
Back buttonCuenavigateHowdirection "back"
Carousel, gallery, paginationCuenavigateHowsubtle, direction
Drag picked up / dropped / cancelledCueselect / tap / closeHow— / strong / subtle
Reorder a list itemCueselectHowdirection
Saved, syncedCuesuccessHowsubtle
Payment, publish, deploy confirmedCuesuccessHowstrong
Partial failure, deprecation, connection retryingCuewarningHow—
Form rejected, permission deniedCueerrorHow—
Upload, export, or build startedCueloadingHow—
Upload finishedCuesuccessHow—
Export ready to downloadCuereadyHow—
Long job finished while the user was awayCuereadyHowstrong
New message in an open conversationCuereadyHowsubtle
Reminder or timer dueCueattentionHow—
A number animates to a new valueCuecountHowduration matching the animation
A number counts downCuecountHowdirection "back"
A live number that updates constantly: price feed, viewersCuenothingHow—
AI: prompt sent / generation startedCuetap / loadingHow— / subtle
AI: generation stoppedCuecloseHow—
AI: reply finished streamingCuereadyHowsubtle
AI: tool call needs approvalCueattentionHow—
AI: agent task doneCuesuccessHow—
AI: model, tool, or network failureCueerrorHow—
AI: suggestion accepted / rejectedCueselect / closeHow— / subtle
AI: switch model or modeCueselectHow—
One playful moment in a professional app, e.g. the AI model pickerCuethe usual cueHowtheme "bubble"

Behavior

The defaults are opinionated so you do not have to be.

One cue per action
An event plays at most one cue, even with nested marked elements or several bound roots. A press and its release are one cue. Click bindings follow native activation, so Enter and Space play too.
Typing that behaves
Only keys that edit text play, never in a password field, at most once every 40ms. Holding Backspace keeps playing until there is nothing left to delete. A key that played lets go on keyup, so inpress every keystroke lands and lifts.
Audible without clipping
One shared boosted output stage keeps cues clear, with native compression protecting overlapping sounds. Everything under 40 Hz is cut before it, so deep notes never spend its headroom on what nobody hears.
Struck, not played back
Tones are modelled on real bars: each overtone dies faster the higher it is, and a brief mallet contact starts the note. Every play of a frequent cue is one strike, a little harder or softer than the last, so no two sound identical. Outcome cues play the same every time.
One faint room
Every cue rings very faintly into one shared, short stereo room, so it sounds placed around the listener rather than inside their head.
One lazy AudioContext
Shared across every sound and created on first use, not on import.
Autoplay-friendly
Suspended audio is resumed where possible, without surfacing an error when a browser blocks it. unlock()starts it from the gesture that turns sound on and says whether it worked.
SSR-safe
Importing on the server is a no-op.

Anything missing?

Say it on @cuelume, or point your coding agent at agents.md and it can wire cuelume into a project on its own.