Danilaa1/cuelume

Cuelume is a curated sound palette, not an audio engine. It gives buttons, links, toggles, and completed actions clear feedback without asking developers to design sounds themselves. Add an attribute, call bind(), done.

TypeScript

1,965

31 commits

updated Oct 5, 2026

See the code

README

Cuelume

Fourteen interaction sounds for the web, one for each interface job. Synthesized live with Web Audio, with no audio files and zero runtime dependencies.

Cuelume is a curated sound palette, not an audio engine. It gives buttons, text fields, menus, dialogs, and finished actions clear feedback without asking developers to design sounds themselves. Add an attribute, call bind(), done.

Install

npm install cuelume

Requirements

Cuelume is ESM-only. Use it through native import or an ESM-compatible bundler; CommonJS require() is not supported.

It targets modern browsers with ES modules and the Web Audio API. Server-side imports are safe, but sound playback only runs in the browser.

Quick start

Add data attributes to your markup:

<button data-cuelume-tap>Save</button>
<input data-cuelume-type placeholder="Search">
<select data-cuelume-select>…</select>
<button data-cuelume-open>Open menu</button>
<button data-cuelume-tap="open">Show details</button>

Then wire everything up once:

import { bind } from "cuelume";

bind();
AttributeFires onDefault cue
data-cuelume-tapclicktap
data-cuelume-typekeydown that edits texttype
data-cuelume-selectchange on a native select or input, else clickselect
data-cuelume-toggleclicktoggle
data-cuelume-openclickopen
data-cuelume-closeclickclose
data-cuelume-navigateclicknavigate

Leave the attribute value empty to use its default cue, or set it to any cue name. When marked elements nest, the innermost one decides.

Outcome and progress cues follow what your app is actually doing, so play them from code:

import { play } from "cuelume";

play("loading");
try {
  const { warnings } = await deploy();
  play(warnings.length ? "warning" : "success");
} catch {
  play("error");
}

play("success", { volume: 0.4 }); // quieter for this play only

Cues

CueJobCharacter
tapButtons, links, direct activationSmall glassy tap
typeText entryKeyboard keystroke, different every stroke
selectDropdowns, menus, listsCrisp woody detent
toggleSwitching between statesOne crisp snap with a knock of body
openMenus, drawers, dialogs, disclosuresAir drawing up over a light mallet note
closeClosing or dismissingAir falling shut over a low, damped note
navigateRoutes, pages, carousels, galleriesSoft whoosh that rises, and falls going back
successConfirmed completionOne soft mallet chord, C–G–E spread wide
warningDone, but needs a lookOne mallet fifth, A and E
errorRecoverable failure or refusalOne muted low chord, short
loadingSlow work startedOne soft, low note that swells and fades
readyA result is there, nothing confirmedOne warm glass note in a small room
attentionBlocked until the user answersOne high glass bell, ringing longest
countA number animating to a new valueOne breath that rises with the count

The material tells you the kind of event before you know which one: mallets for outcomes, glass for presence, air for motion, wood and keys for input.

Which cue?

Pick by the job, not by the sound. Every row uses a cue that exists; emphasis and options do the rest.

MomentCue
Primary button: save, send, submittap
Secondary or ghost buttontap, subtle
Destructive confirm: delete, removeclose, strong
Copy to clipboardsuccess, subtle
Like, star, bookmarktoggle
Undo / redonavigate, subtle, direction: "back" / "forward"
Keystroke, delete, space, returntype (bindings set key)
Autocomplete acceptedselect
Field fails validationerror, subtle
Menu, dropdown, or list optionselect
Tab, segmented control, radioselect (bindings set direction)
Checkbox, switchtoggle (bindings set direction)
Slider or stepper stepselect, subtle, direction
Menu or popover opens / closesopen / close, subtle
Dialog or drawer opens / closesopen / close
Accordion expands / collapsesopen / close, subtle
Command paletteopen, subtle, or nothing: it fires hundreds of times a day
Toastthe cue for what it reports, subtle
Route changenavigate
Back buttonnavigate, direction: "back"
Carousel, gallery, paginationnavigate, subtle, direction
Drag picked up / dropped / cancelledselect / tap, strong / close, subtle
Reorder a list itemselect, direction
Saved, syncedsuccess, subtle
Payment, publish, deploy confirmedsuccess, strong
Partial failure, deprecation, connection retryingwarning
Form rejected, permission deniederror
Upload, export, or build startedloading
Upload finishedsuccess
Export ready to downloadready
Long job finished while the user was awayready, strong
New message in an open conversationready, subtle
Reminder or timer dueattention
A number animates to a new valuecount, duration matching the animation
A number counts downcount, direction: "back"
A live number that updates constantly: price feed, viewersnothing
AI: prompt sent / generation startedtap / loading, subtle
AI: generation stoppedclose
AI: reply finished streamingready, subtle
AI: tool call needs approvalattention
AI: agent task done (PR opened, file written)success
AI: model, tool, or network failureerror
AI: suggestion accepted / rejectedselect / close, subtle
AI: switch model or modeselect
One playful moment in a professional app, e.g. the AI model pickerthe usual cue, theme: "bubble"

Never play a cue per streamed token, per tool call inside an agent run, or on hover. Those fire in bursts and wear any sound out; play one cue when the run ends. An animated number gets one count when it starts moving, never one per digit.

Typing

data-cuelume-type plays only on the field that carries it, and only for keys that edit text.

  • Printable characters, Space, Backspace, and Delete play. Enter plays in a textarea or contenteditable, but not in a single-line input, where it submits.
  • Password fields never play.
  • Holding Backspace or Delete keeps playing as it deletes, and stops once there is nothing left to delete. Deleting a word or line with Option, Cmd, or Ctrl plays too.
  • Modifier keys, arrows, shortcuts like Cmd+C, other held keys, and IME composition are ignored.
  • Fast typing plays at most one sound every 40ms, and every keystroke lands at a slightly different pitch and weight, so a sentence never sounds like one key repeated.

Selection

A native <select data-cuelume-select> plays when its value changes, not when it opens. Marked radio and checkbox inputs work the same way.

A custom option or menu item plays on click. A menu item built correctly fires click on Enter and Space too, so keyboard selection gets the same cue.

Counting

Play count once when a number starts animating to a new value, and pass the animation's length so the roll lands with it:

play("count", { duration: 900 });                    // 0 → 1,284
play("count", { duration: 400, direction: "back" }); // 12 → 3

One breath rises with the number (and falls counting down), lasting as long as the roll. duration is in milliseconds, clamped to 300–2000; without it the roll takes 800 ms. Leave numbers that change more than about once a second, like a live price, silent.

Context-aware

The same cue bends to how the interaction happened. Cuelume reads this from the event itself, so there is nothing to configure.

CueListens toWhat changes
typeTyping speed, which keyFast typing gets lighter and drops the key-return click. Space sounds bigger, Enter heaviest, Backspace and Delete lower.
selectWhich way the selection movedA later option rises slightly and an earlier one falls. Custom options count among their marked siblings; a native select uses selectedIndex.
tapWhat activated it, rapid repeatsTouch sounds softer and rounder than a mouse click, keyboard activation shorter, a pen crisper. Quick repeats get lighter.
navigatedirection, from play()back plays the whoosh falling instead of rising.
toggleWhich way it switchedSwitching off plays its glides backwards: default and mech knock upward, bubble's cork sinks.
countduration and direction, from play()The roll stretches to the animation; back falls.

Every change is small and bounded, so a cue always sounds like itself. When there's no context, as with a plain play() call, the cue plays as normal.

When you play a cue from code, pass the same context yourself:

play("navigate", { direction: "back" });
play("select", { direction: "forward" });
play("type", { key: "delete" });
play("tap", { input: "keyboard", emphasis: "subtle" });

A field a cue doesn't listen to is ignored, and an unknown value plays the cue as normal.

The one thing Cuelume can't infer is how much an action matters. Set that with emphasis, per call or per element:

play("success", { emphasis: "strong" }); // publish, pay, send
play("success", { emphasis: "subtle" }); // autosave, a small confirmation
<button data-cuelume-tap data-cuelume-emphasis="subtle">Skip</button>
<div data-cuelume-emphasis="subtle">…a whole quiet toolbar…</div>

Each level is its own arrangement of the cue, so fourteen cues give you 42 distinct sounds to choose from. subtle strips the ornament: the glass tap loses its nail tick, typing becomes a quiet laptop keyboard, success turns into a felt mallet. strong adds a layer only it plays: a bigger glass, a deep mechanical bottom-out, an octave held under the second success note. Emphasis is not a volume knob: volume still scales loudness on its own, and the two combine. Invalid values play as normal.

Context comes from the current event and the timing of recent plays on the same page. Nothing is stored, sent, or kept across page loads, and Cuelume never guesses importance from class names, copy, or layout.

Themes

Every cue comes in four finished materials. default is warm: glass, wood, air, and soft mallets. mech is dry and precise: a shutter click, a ratchet detent, a latch, struck metal. bubble is playful, and every cue is its own gesture: a knock, a drip, a cork, a gulp, a kalimba, a zip. press is one premium switch: every interaction is a crisp click over the knock of whatever it moves, and tap lets its body ring like a trackpad under a finger. Outcomes are not touched, so they do not click: success, error, warning, ready and attention are that warm note on its own, with a chord that says what happened, and loading is one soft, low note that swells and fades. Whatever the theme, every cue is one sound: it strikes once. All four have the same fourteen cues, context, and emphasis, and they're level-matched, so switching changes the material, not the volume.

import { play, setTheme } from "cuelume";

setTheme("mech");    // future plays use the mech material
setTheme("default");

play("select", { theme: "bubble" }); // one playful moment; the theme stays default
<div data-cuelume-theme="bubble">
  <select data-cuelume-select>…</select>
</div>

setTheme applies to sounds played after the call. The theme option and data-cuelume-theme (on an element or any ancestor; the innermost wins) apply to one play and leave the active theme alone. Unknown names are ignored, and like volume, the choice isn't stored.

default and mech sit in a calm register for all-day use. Reach for bubble when a product, or one moment in it, should feel playful, and press when it should feel like touching hardware: clicky, deep and precise.

press is modelled on the trackpad and keycap sounds in DawoodUI's Artasaka preview.

Sound settings

Cuelume starts enabled at full volume and never reads or writes storage. Your app owns the controls, labels, and persistence:

import { setEnabled, setVolume } from "cuelume";

setVolume(0.7);    // global multiplier, clamped to 0–1
setEnabled(false); // future play attempts become no-ops
setEnabled(true);  // enable playback again

If your app plays sound often, or the sound is decoration rather than information, give people a visible Sound toggle and pass its value to setEnabled().

Cuelume does not follow prefers-reduced-motion. That setting is about motion. Someone who turns off animation may still want sound, and someone who wants silence may never touch it.

API

import { play, bind, setEnabled, setVolume, setTheme, sounds, themes, type SoundName, type Emphasis, type ThemeName, type PlayOptions } from "cuelume";
  • play(name?: SoundName, options?: PlayOptions): play a cue immediately. Defaults to "tap". Options apply to this play only:
    • volume?: number, clamped to 0–1.
    • emphasis?: "subtle" | "normal" | "strong".
    • direction?: "forward" | "back": shapes select; back plays navigate, toggle, and count backwards.
    • key?: "printable" | "space" | "delete" | "enter": shapes type.
    • input?: "mouse" | "touch" | "pen" | "keyboard": shapes tap.
    • theme?: ThemeName: the material for this play only.
    • duration?: number: how long a count runs, in milliseconds, clamped to 300–2000.
  • bind(root?: ParentNode): delegate all data-cuelume-* interactions under root (defaults to the whole document). Idempotent, and handles elements added later.
  • setEnabled(enabled: boolean): enable or disable future playback. Does not persist the preference or stop sounds already playing.
  • setVolume(volume: number): set the global volume for future playback, clamped to 0–1. Non-finite values are ignored and preferences are not persisted.
  • setTheme(theme: ThemeName): switch the material of future playback. Unknown names are ignored; the choice isn't persisted.
  • themes: the built-in theme names, ["default", "mech", "bubble", "press"].
  • sounds: the fourteen cue names.
  • SoundName: union type of the fourteen cue names.
  • Emphasis: "subtle" | "normal" | "strong".
  • ThemeName: "default" | "mech" | "bubble" | "press".

Migrating from 0.2

0.3 replaces the seventeen-sound palette with the fourteen cues above. Until 1.0, the old names still play the cue that now does their job, and TypeScript marks them deprecated.

0.2 namePlays
chime, sparklesuccess
press, release, pulsetap
tick, whisper, scanselect
bloomopen
dropletclose
page, arrivalnavigate
toggle, success, error, loading, readyunchanged

play() with no name now plays tap instead of chime.

The old bindings also keep working until 1.0:

0.2 bindingIn 0.3
data-cuelume-hoverPlays select on hover. Hover is no longer a built-in binding; call play() from your own handler if you want it.
data-cuelume-pressPlays tap on pointer down. Use data-cuelume-tap.
data-cuelume-releasePlays tap on pointer up, unless the element also has data-cuelume-press, so an old press/release pair plays one tap.

Defaults that behave

  • One cue per action. An event plays at most one cue, even with nested marked elements or several bound roots.
  • Keyboard included. Click bindings follow native activation, so Enter and Space on a button play the same cue as a click.
  • Audible without clipping. One shared boosted output stage keeps sounds clear, with native compression protecting overlapping cues.
  • 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 a harder one is louder and brighter together and no two sound identical. Outcome cues play the same every time, so their meaning never blurs.
  • One faint room. Every cue rings very faintly into one shared, short stereo room (about 22 dB down), which places it in the space around the listener rather than inside their head. success and ready ring into it a little more.
  • One lazy AudioContext. Shared across all sounds, created on first use.
  • Autoplay-friendly. Attempts to resume suspended audio without surfacing errors when a browser blocks it.
  • SSR-safe. Importing on the server is a no-op.
  • Safe fallback. Invalid runtime names and unavailable or blocked Web Audio make play() a silent no-op. An invalid attribute value plays that binding's default cue.
  • Dynamic, idempotent binding. bind() never double-attaches listeners, and later DOM additions, removals, and clones work automatically.

Frameworks

Cuelume works anywhere HTML does: plain pages, Astro, React, Vue. Call bind() once after the DOM is ready. Delegated listeners keep working when components or routes replace descendants.

React:

useEffect(() => {
  bind();
}, []);

Astro (with view transitions):

import { bind, play } from "cuelume";

bind();
document.addEventListener("astro:page-load", () => play("navigate"));

Browsers block audio on a fresh visit until the user interacts with the page. The navigate cue therefore plays on client-side navigations after that first interaction.

Claude Code

Cuelume also ships as a Claude Code plugin with two cues: ready when a turn of ten seconds or more finishes, and attention when a permission prompt is waiting on you.

/plugin marketplace add danielwh2/cuelume
/plugin install cuelume@cuelume

/cuelume bubble switches the theme and plays it, and /cuelume off silences it. See claude-code/README.md.

License

MIT

Danilaa1/cuelume

Cuelume is a curated sound palette, not an audio engine. It gives buttons, links, toggles, and completed actions clear feedback without asking developers to design sounds themselves. Add an attribute, call bind(), done.

TypeScript

1,965

31 commits

updated Oct 5, 2026

See the code

README

Cuelume

Fourteen interaction sounds for the web, one for each interface job. Synthesized live with Web Audio, with no audio files and zero runtime dependencies.

Cuelume is a curated sound palette, not an audio engine. It gives buttons, text fields, menus, dialogs, and finished actions clear feedback without asking developers to design sounds themselves. Add an attribute, call bind(), done.

Install

npm install cuelume

Requirements

Cuelume is ESM-only. Use it through native import or an ESM-compatible bundler; CommonJS require() is not supported.

It targets modern browsers with ES modules and the Web Audio API. Server-side imports are safe, but sound playback only runs in the browser.

Quick start

Add data attributes to your markup:

<button data-cuelume-tap>Save</button>
<input data-cuelume-type placeholder="Search">
<select data-cuelume-select>…</select>
<button data-cuelume-open>Open menu</button>
<button data-cuelume-tap="open">Show details</button>

Then wire everything up once:

import { bind } from "cuelume";

bind();
AttributeFires onDefault cue
data-cuelume-tapclicktap
data-cuelume-typekeydown that edits texttype
data-cuelume-selectchange on a native select or input, else clickselect
data-cuelume-toggleclicktoggle
data-cuelume-openclickopen
data-cuelume-closeclickclose
data-cuelume-navigateclicknavigate

Leave the attribute value empty to use its default cue, or set it to any cue name. When marked elements nest, the innermost one decides.

Outcome and progress cues follow what your app is actually doing, so play them from code:

import { play } from "cuelume";

play("loading");
try {
  const { warnings } = await deploy();
  play(warnings.length ? "warning" : "success");
} catch {
  play("error");
}

play("success", { volume: 0.4 }); // quieter for this play only

Cues

CueJobCharacter
tapButtons, links, direct activationSmall glassy tap
typeText entryKeyboard keystroke, different every stroke
selectDropdowns, menus, listsCrisp woody detent
toggleSwitching between statesOne crisp snap with a knock of body
openMenus, drawers, dialogs, disclosuresAir drawing up over a light mallet note
closeClosing or dismissingAir falling shut over a low, damped note
navigateRoutes, pages, carousels, galleriesSoft whoosh that rises, and falls going back
successConfirmed completionOne soft mallet chord, C–G–E spread wide
warningDone, but needs a lookOne mallet fifth, A and E
errorRecoverable failure or refusalOne muted low chord, short
loadingSlow work startedOne soft, low note that swells and fades
readyA result is there, nothing confirmedOne warm glass note in a small room
attentionBlocked until the user answersOne high glass bell, ringing longest
countA number animating to a new valueOne breath that rises with the count

The material tells you the kind of event before you know which one: mallets for outcomes, glass for presence, air for motion, wood and keys for input.

Which cue?

Pick by the job, not by the sound. Every row uses a cue that exists; emphasis and options do the rest.

MomentCue
Primary button: save, send, submittap
Secondary or ghost buttontap, subtle
Destructive confirm: delete, removeclose, strong
Copy to clipboardsuccess, subtle
Like, star, bookmarktoggle
Undo / redonavigate, subtle, direction: "back" / "forward"
Keystroke, delete, space, returntype (bindings set key)
Autocomplete acceptedselect
Field fails validationerror, subtle
Menu, dropdown, or list optionselect
Tab, segmented control, radioselect (bindings set direction)
Checkbox, switchtoggle (bindings set direction)
Slider or stepper stepselect, subtle, direction
Menu or popover opens / closesopen / close, subtle
Dialog or drawer opens / closesopen / close
Accordion expands / collapsesopen / close, subtle
Command paletteopen, subtle, or nothing: it fires hundreds of times a day
Toastthe cue for what it reports, subtle
Route changenavigate
Back buttonnavigate, direction: "back"
Carousel, gallery, paginationnavigate, subtle, direction
Drag picked up / dropped / cancelledselect / tap, strong / close, subtle
Reorder a list itemselect, direction
Saved, syncedsuccess, subtle
Payment, publish, deploy confirmedsuccess, strong
Partial failure, deprecation, connection retryingwarning
Form rejected, permission deniederror
Upload, export, or build startedloading
Upload finishedsuccess
Export ready to downloadready
Long job finished while the user was awayready, strong
New message in an open conversationready, subtle
Reminder or timer dueattention
A number animates to a new valuecount, duration matching the animation
A number counts downcount, direction: "back"
A live number that updates constantly: price feed, viewersnothing
AI: prompt sent / generation startedtap / loading, subtle
AI: generation stoppedclose
AI: reply finished streamingready, subtle
AI: tool call needs approvalattention
AI: agent task done (PR opened, file written)success
AI: model, tool, or network failureerror
AI: suggestion accepted / rejectedselect / close, subtle
AI: switch model or modeselect
One playful moment in a professional app, e.g. the AI model pickerthe usual cue, theme: "bubble"

Never play a cue per streamed token, per tool call inside an agent run, or on hover. Those fire in bursts and wear any sound out; play one cue when the run ends. An animated number gets one count when it starts moving, never one per digit.

Typing

data-cuelume-type plays only on the field that carries it, and only for keys that edit text.

  • Printable characters, Space, Backspace, and Delete play. Enter plays in a textarea or contenteditable, but not in a single-line input, where it submits.
  • Password fields never play.
  • Holding Backspace or Delete keeps playing as it deletes, and stops once there is nothing left to delete. Deleting a word or line with Option, Cmd, or Ctrl plays too.
  • Modifier keys, arrows, shortcuts like Cmd+C, other held keys, and IME composition are ignored.
  • Fast typing plays at most one sound every 40ms, and every keystroke lands at a slightly different pitch and weight, so a sentence never sounds like one key repeated.

Selection

A native <select data-cuelume-select> plays when its value changes, not when it opens. Marked radio and checkbox inputs work the same way.

A custom option or menu item plays on click. A menu item built correctly fires click on Enter and Space too, so keyboard selection gets the same cue.

Counting

Play count once when a number starts animating to a new value, and pass the animation's length so the roll lands with it:

play("count", { duration: 900 });                    // 0 → 1,284
play("count", { duration: 400, direction: "back" }); // 12 → 3

One breath rises with the number (and falls counting down), lasting as long as the roll. duration is in milliseconds, clamped to 300–2000; without it the roll takes 800 ms. Leave numbers that change more than about once a second, like a live price, silent.

Context-aware

The same cue bends to how the interaction happened. Cuelume reads this from the event itself, so there is nothing to configure.

CueListens toWhat changes
typeTyping speed, which keyFast typing gets lighter and drops the key-return click. Space sounds bigger, Enter heaviest, Backspace and Delete lower.
selectWhich way the selection movedA later option rises slightly and an earlier one falls. Custom options count among their marked siblings; a native select uses selectedIndex.
tapWhat activated it, rapid repeatsTouch sounds softer and rounder than a mouse click, keyboard activation shorter, a pen crisper. Quick repeats get lighter.
navigatedirection, from play()back plays the whoosh falling instead of rising.
toggleWhich way it switchedSwitching off plays its glides backwards: default and mech knock upward, bubble's cork sinks.
countduration and direction, from play()The roll stretches to the animation; back falls.

Every change is small and bounded, so a cue always sounds like itself. When there's no context, as with a plain play() call, the cue plays as normal.

When you play a cue from code, pass the same context yourself:

play("navigate", { direction: "back" });
play("select", { direction: "forward" });
play("type", { key: "delete" });
play("tap", { input: "keyboard", emphasis: "subtle" });

A field a cue doesn't listen to is ignored, and an unknown value plays the cue as normal.

The one thing Cuelume can't infer is how much an action matters. Set that with emphasis, per call or per element:

play("success", { emphasis: "strong" }); // publish, pay, send
play("success", { emphasis: "subtle" }); // autosave, a small confirmation
<button data-cuelume-tap data-cuelume-emphasis="subtle">Skip</button>
<div data-cuelume-emphasis="subtle">…a whole quiet toolbar…</div>

Each level is its own arrangement of the cue, so fourteen cues give you 42 distinct sounds to choose from. subtle strips the ornament: the glass tap loses its nail tick, typing becomes a quiet laptop keyboard, success turns into a felt mallet. strong adds a layer only it plays: a bigger glass, a deep mechanical bottom-out, an octave held under the second success note. Emphasis is not a volume knob: volume still scales loudness on its own, and the two combine. Invalid values play as normal.

Context comes from the current event and the timing of recent plays on the same page. Nothing is stored, sent, or kept across page loads, and Cuelume never guesses importance from class names, copy, or layout.

Themes

Every cue comes in four finished materials. default is warm: glass, wood, air, and soft mallets. mech is dry and precise: a shutter click, a ratchet detent, a latch, struck metal. bubble is playful, and every cue is its own gesture: a knock, a drip, a cork, a gulp, a kalimba, a zip. press is one premium switch: every interaction is a crisp click over the knock of whatever it moves, and tap lets its body ring like a trackpad under a finger. Outcomes are not touched, so they do not click: success, error, warning, ready and attention are that warm note on its own, with a chord that says what happened, and loading is one soft, low note that swells and fades. Whatever the theme, every cue is one sound: it strikes once. All four have the same fourteen cues, context, and emphasis, and they're level-matched, so switching changes the material, not the volume.

import { play, setTheme } from "cuelume";

setTheme("mech");    // future plays use the mech material
setTheme("default");

play("select", { theme: "bubble" }); // one playful moment; the theme stays default
<div data-cuelume-theme="bubble">
  <select data-cuelume-select>…</select>
</div>

setTheme applies to sounds played after the call. The theme option and data-cuelume-theme (on an element or any ancestor; the innermost wins) apply to one play and leave the active theme alone. Unknown names are ignored, and like volume, the choice isn't stored.

default and mech sit in a calm register for all-day use. Reach for bubble when a product, or one moment in it, should feel playful, and press when it should feel like touching hardware: clicky, deep and precise.

press is modelled on the trackpad and keycap sounds in DawoodUI's Artasaka preview.

Sound settings

Cuelume starts enabled at full volume and never reads or writes storage. Your app owns the controls, labels, and persistence:

import { setEnabled, setVolume } from "cuelume";

setVolume(0.7);    // global multiplier, clamped to 0–1
setEnabled(false); // future play attempts become no-ops
setEnabled(true);  // enable playback again

If your app plays sound often, or the sound is decoration rather than information, give people a visible Sound toggle and pass its value to setEnabled().

Cuelume does not follow prefers-reduced-motion. That setting is about motion. Someone who turns off animation may still want sound, and someone who wants silence may never touch it.

API

import { play, bind, setEnabled, setVolume, setTheme, sounds, themes, type SoundName, type Emphasis, type ThemeName, type PlayOptions } from "cuelume";
  • play(name?: SoundName, options?: PlayOptions): play a cue immediately. Defaults to "tap". Options apply to this play only:
    • volume?: number, clamped to 0–1.
    • emphasis?: "subtle" | "normal" | "strong".
    • direction?: "forward" | "back": shapes select; back plays navigate, toggle, and count backwards.
    • key?: "printable" | "space" | "delete" | "enter": shapes type.
    • input?: "mouse" | "touch" | "pen" | "keyboard": shapes tap.
    • theme?: ThemeName: the material for this play only.
    • duration?: number: how long a count runs, in milliseconds, clamped to 300–2000.
  • bind(root?: ParentNode): delegate all data-cuelume-* interactions under root (defaults to the whole document). Idempotent, and handles elements added later.
  • setEnabled(enabled: boolean): enable or disable future playback. Does not persist the preference or stop sounds already playing.
  • setVolume(volume: number): set the global volume for future playback, clamped to 0–1. Non-finite values are ignored and preferences are not persisted.
  • setTheme(theme: ThemeName): switch the material of future playback. Unknown names are ignored; the choice isn't persisted.
  • themes: the built-in theme names, ["default", "mech", "bubble", "press"].
  • sounds: the fourteen cue names.
  • SoundName: union type of the fourteen cue names.
  • Emphasis: "subtle" | "normal" | "strong".
  • ThemeName: "default" | "mech" | "bubble" | "press".

Migrating from 0.2

0.3 replaces the seventeen-sound palette with the fourteen cues above. Until 1.0, the old names still play the cue that now does their job, and TypeScript marks them deprecated.

0.2 namePlays
chime, sparklesuccess
press, release, pulsetap
tick, whisper, scanselect
bloomopen
dropletclose
page, arrivalnavigate
toggle, success, error, loading, readyunchanged

play() with no name now plays tap instead of chime.

The old bindings also keep working until 1.0:

0.2 bindingIn 0.3
data-cuelume-hoverPlays select on hover. Hover is no longer a built-in binding; call play() from your own handler if you want it.
data-cuelume-pressPlays tap on pointer down. Use data-cuelume-tap.
data-cuelume-releasePlays tap on pointer up, unless the element also has data-cuelume-press, so an old press/release pair plays one tap.

Defaults that behave

  • One cue per action. An event plays at most one cue, even with nested marked elements or several bound roots.
  • Keyboard included. Click bindings follow native activation, so Enter and Space on a button play the same cue as a click.
  • Audible without clipping. One shared boosted output stage keeps sounds clear, with native compression protecting overlapping cues.
  • 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 a harder one is louder and brighter together and no two sound identical. Outcome cues play the same every time, so their meaning never blurs.
  • One faint room. Every cue rings very faintly into one shared, short stereo room (about 22 dB down), which places it in the space around the listener rather than inside their head. success and ready ring into it a little more.
  • One lazy AudioContext. Shared across all sounds, created on first use.
  • Autoplay-friendly. Attempts to resume suspended audio without surfacing errors when a browser blocks it.
  • SSR-safe. Importing on the server is a no-op.
  • Safe fallback. Invalid runtime names and unavailable or blocked Web Audio make play() a silent no-op. An invalid attribute value plays that binding's default cue.
  • Dynamic, idempotent binding. bind() never double-attaches listeners, and later DOM additions, removals, and clones work automatically.

Frameworks

Cuelume works anywhere HTML does: plain pages, Astro, React, Vue. Call bind() once after the DOM is ready. Delegated listeners keep working when components or routes replace descendants.

React:

useEffect(() => {
  bind();
}, []);

Astro (with view transitions):

import { bind, play } from "cuelume";

bind();
document.addEventListener("astro:page-load", () => play("navigate"));

Browsers block audio on a fresh visit until the user interacts with the page. The navigate cue therefore plays on client-side navigations after that first interaction.

Claude Code

Cuelume also ships as a Claude Code plugin with two cues: ready when a turn of ten seconds or more finishes, and attention when a permission prompt is waiting on you.

/plugin marketplace add danielwh2/cuelume
/plugin install cuelume@cuelume

/cuelume bubble switches the theme and plays it, and /cuelume off silences it. See claude-code/README.md.

License

MIT

Project health

Latest release

0.2.4 · Sep 29, 2026

Releases this year

6