docs · generated from the README ← back to the demo

foley

npm CI license

Sound effects for the interface, performed live. Play the demo →

Foley is a tiny, dependency-free library of 28 interaction sounds, named for the film artists who perform footsteps and door-latches in sync with the picture. It does the same for your interface: every cue is synthesized with Web Audio at the instant of the action. No audio files, no network requests, no build step.

Sibling to Dolly (repo), which does scroll-driven motion the same way — one attribute, no JavaScript.

Install

npm install @foleyjs/core

Published under the foleyjs org — the bare name foley is blocked by npm's package-name similarity rules. Framework bindings will join as @foleyjs/react and friends.

Or skip the install entirely and vendor the single file: copy src/foley.js into your project.

Quickstart

Mark up anything that should make a sound, then bind once at startup.

<button data-foley-press data-foley-release>Save</button>
<a data-foley-hover="tick">Docs</a>
<button data-foley-toggle aria-pressed="false">Dark mode</button>
<input data-foley-type="thock">
import { bind, play, set } from "@foleyjs/core";

bind();                          // wires every data-foley-* attribute
set({ volume: 0.7, theme: "default" });

play("success");                 // programmatic cues for things the user didn't click
play("tick", { pitch: 4, volume: 0.3 });

Browsers require a user gesture before audio can start; bind() installs a one-time unlock listener for you, and play() resumes the context automatically.

Declarative attributes

Attribute Fires on Default cue
data-foley-hover pointerenter, focusin tick
data-foley-press pointerdown press
data-foley-release pointerup release
data-foley-click click tap
data-foley-toggle click (reads aria-pressed) on / off
data-foley-type keydown (Enter plays complete) thock

Every attribute accepts a cue name as its value to override the default.

bind() delegates from root, so it covers elements that don't exist yet — render a modal, change routes, append a list item, and they all sound without re-binding. Hover also fires on keyboard focus, so tabbing through an interface sounds like moving through it.

Sounds the user didn't trigger

bind() covers what someone does. observe() covers what the interface does on its own — a toast arriving, a dialog closing, a drawer flipping open:

<div role="status" data-foley-enter="bubble">Saved</div>
<div class="modal" data-foley-exit="whoosh">…</div>
<details data-foley-change="switch" open>…</details>
import { bind, observe } from "@foleyjs/core";
bind();      // interaction
observe();   // appearance and state change
Attribute Fires when Default cue
data-foley-enter the element is added to the DOM bubble
data-foley-exit the element is removed whoosh
data-foley-change a state attribute on it changes switch

One MutationObserver, so no lifecycle hooks and no state plumbing — mount a toast and it sounds. It's a separate call from bind() on purpose: it watches for the life of the page, and most apps only want interaction sounds.

The 28 cues

Family Cues
Pointer tick hover glide pop
Press press release tap thock
Toggle on off switch latch
Feedback success error warning denied
Notify chime ping bell bubble
Motion swoosh whoosh drop rise
State loading ready complete sparkle

API

import { play, bind, set, get, toWav, unlock, getAnalyser, on, cues, families, themes, version } from "@foleyjs/core";

Placement

A cue can come from somewhere. pan puts it in the stereo field; pos puts it in 3D (HRTF, inverse distance) for WebXR and canvas scenes.

play("tick", { pan: -0.7 });              // over on the left
play("ping", { pos: [1, 0, -0.5] });      // to the right, slightly in front
set({ localize: 0.6 });                   // every bound cue pans to its own button

pos is measured from a listener at the origin, and attenuates past about 1 unit (inverse distance). Keep cues within a unit or two unless you want them faint — [1, 0, -0.5] is full volume and clearly to the right, while [2, 0, -3] is already about 11 dB down.

One setting and the interface stops sounding like it comes from a single point — a toolbar on the right clicks on the right, which is the thing a screen-shaped sound library can do that a game audio engine never bothered to.

Placement is fixed at the trigger: cues are ~200ms one-shots, over before anything could move, so there is no listener, no cones, and nothing to reposition mid-flight. The reverb send stays center — rooms don't pan, sources do. Exports (toWav, toSprite) stay centered too: position is a property of the performance, not of the sound, the same rule humanization follows.

Design your own cues

Every cue is data: an array of tone, noise, and cluster layers. Grab one, reshape it, play it:

import { getSpec, playSpec, toWavSpec } from "@foleyjs/core";

const mySound = getSpec("success");
mySound[2].f = 880;                  // raise the last note
mySound.push({ kind: "noise", at: 0.2, filter: "highpass", f: 6000,
               a: 0.01, d: 0.2, peak: 0.05, send: 0.4 });
playSpec(mySound);
const wav = await toWavSpec(mySound);

In TypeScript, narrow on kind before touching a layer's fields — Spec is a union, and cluster layers carry fMin/fMax where tone and noise layers carry f:

const layer = mySound[2];
if (layer.kind === "tone") layer.f = 880;

Or use the visual Cue Designer on the demo — edit with live playback, then export .wav/.json or share the design as a link.

Sound sets

A sound set is your product's whole sonic identity as one portable JSON object: a global character transform plus full replacement specs for the cues that matter most.

import { set, getSet } from "@foleyjs/core";

set({ theme: {
  name: "Acme",
  transform: { pitch: 0.9, decay: 1.3 },      // every cue, reshaped
  cues: { success: [/* layers */], error: [/* layers */] }  // these two, replaced
}});

get().theme;   // "Acme"
getSet();      // snapshot the active identity - JSON-safe, version it in your repo

Themes

One setting reshapes all 28 cues — waveforms, envelopes, brightness, noise character, and reverb:

set({ theme: "glass" }); // "default" | "soft" | "mechanical" | "glass"

Soft rounds every waveform and adds room. Mechanical halves the transients and dries the space — machined metal. Glass pitches up, rings the filters, and grows an inharmonic partial on every voice — the physics trick that makes struck glass sound like glass.

Engine behavior you get for free

Framework packages

Both re-export everything from the core, share its version number, and declare it as a peer dependency.

Run the demo

The demo page (index.html) imports src/foley.js directly — it runs exactly what the package ships. ES modules need a server:

npm run demo        # or: npx serve .

Then open the printed URL. GitHub Pages works too: enable it on the repo root and the demo is live.

Development

npm install      # once per clone — the test suite imports the framework packages,
                 # which need react and the self-linked core from node_modules
npm test         # node --test: metadata integrity, settings, docs/types consistency, build
npm run build    # regenerate the single-file demo at dist/foley-demo.html
npm run docs     # render README.md into dist/docs.html (deployed at /docs.html)
npm run demo     # serve the demo locally

Contributions welcome — see CONTRIBUTING.md. Releases follow RELEASING.md. CI runs the tests on every push; merges to main deploy the demo to GitHub Pages (enable Pages with the "GitHub Actions" source in repo settings, once). Pushing a v* tag publishes all three packages to npm from CI via trusted publishing — no tokens. One-time setup: publish each package manually once, then in each package’s npm settings add a Trusted Publisher pointing at this repo and release.yml. Provenance is automatic.

License

MIT © eakbulut and Foley contributors.