Skip to content

New200 animated icons, 75 free. See the icons ›

Nexus UI

Add sound to your icons.

Every one of the 200 icons has a sound of its own, scored note by note against its animation. There are no audio files: it is all synthesised in the browser. Copy the engine once, copy the sounds you want, and call one function.

Hear a few first

Press an icon to play it with its sound. Every icon page has its own preview, and the Sound switch on the icons page plays the whole set.

Three steps

  1. 1

    Copy the engine

    Two small files do all the work. Save sound-engine.ts and sound-kit.ts side by side, for example in components/icons/. They have no dependencies.

  2. 2

    Copy the icon's sound

    Every icon page has a Sound section with that icon's score. Save it as components/icons/sounds/<icon>.ts, next to sound-kit.ts. Take only the icons you use.

  3. 3

    Call playIconScore

    Call it from a click, tap or hover handler, passing the score. That is the whole integration.

Using Next.js? Put the files in a client component folder: the handlers that call playIconScore need "use client". Nothing else is required: no provider, no setup call, no audio assets.

1. The engine

Creates one shared audio context, draws notes and bursts of noise, remembers the visitor's on/off choice and exposes playIconScore.

/**
* A tiny sound engine for the animated icons. It has no dependencies: copy this file, sound-kit.ts and the
* score of every icon you want to hear, and call playIconScore(score). Nothing is downloaded: every sound
* is synthesised in the browser with the Web Audio API, note by note, against the animation it belongs to.
*/
 
/** Stretches or squeezes time, so a sound follows the icon's `speed` prop. */
export type ScoreTime = (seconds: number) => number;
 
/** What every icon's sound file exports. */
export type IconScore = {
/** How long it plays at normal speed, in seconds. */
length: number;
/** How loud it is next to the other icons (measured, so they all sound about equally loud). */
level: number;
play: (c: AudioContext, out: AudioNode, t: ScoreTime) => void;
};
 
const STORAGE_KEY = "nx-icon-sounds";
 
let ctx: AudioContext | null = null;
let master: GainNode | null = null;
let noiseBuf: AudioBuffer | null = null;
let enabled: boolean | null = null;
const listeners = new Set<() => void>();
 
function audio(): AudioContext | null {
if (typeof window === "undefined") return null;
if (!ctx) {
const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return null;
ctx = new AC();
master = ctx.createGain();
master.gain.value = 0.5;
const comp = ctx.createDynamicsCompressor();
master.connect(comp).connect(ctx.destination);
}
if (ctx.state === "suspended") void ctx.resume();
return ctx;
}
 
function noise(c: AudioContext) {
if (!noiseBuf) {
noiseBuf = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = noiseBuf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
const s = c.createBufferSource();
s.buffer = noiseBuf;
return s;
}
 
export type ToneOpts = { freq: number; to?: number; type?: OscillatorType; at?: number; attack?: number; decay?: number; gain?: number; glide?: number };
 
/** One note: an oscillator with a quick attack and an exponential fade. `to` glides the pitch. */
export function tone(c: AudioContext, out: AudioNode, o: ToneOpts) {
const t0 = c.currentTime + (o.at ?? 0);
const osc = c.createOscillator();
const g = c.createGain();
osc.type = o.type ?? "sine";
osc.frequency.setValueAtTime(o.freq, t0);
if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.glide ?? o.decay ?? 0.1));
const a = o.attack ?? 0.004, d = o.decay ?? 0.12, peak = o.gain ?? 0.3;
g.gain.value = 0.0001; // a gain node starts at 1: without this the first sample of a note can click
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + a);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + a + d);
osc.connect(g).connect(out);
osc.start(t0);
osc.stop(t0 + a + d + 0.05);
}
 
export type NoiseOpts = { at?: number; attack?: number; decay?: number; gain?: number; filter?: BiquadFilterType; freq?: number; to?: number; q?: number };
 
/** A burst of filtered noise: a click, a rustle, a puff of air. `to` sweeps the filter. */
export function hiss(c: AudioContext, out: AudioNode, o: NoiseOpts) {
const t0 = c.currentTime + (o.at ?? 0);
const n = noise(c);
const f = c.createBiquadFilter();
const g = c.createGain();
f.type = o.filter ?? "bandpass";
f.frequency.setValueAtTime(o.freq ?? 2000, t0);
if (o.to) f.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.attack ?? 0.01) + (o.decay ?? 0.1));
f.Q.value = o.q ?? 1;
const a = o.attack ?? 0.003, d = o.decay ?? 0.08, peak = o.gain ?? 0.3;
g.gain.value = 0.0001;
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(peak, t0 + a);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + a + d);
n.connect(f).connect(g).connect(out);
n.start(t0, Math.random() * 0.5);
n.stop(t0 + a + d + 0.05);
}
 
function load() {
if (enabled !== null) return;
enabled = true;
try {
const v = window.localStorage.getItem(STORAGE_KEY);
if (v === "off") enabled = false;
} catch {
// storage is blocked: sounds stay on until switched off for this visit
}
}
 
/** Whether icon sounds are on. They are on by default; give visitors a switch (see setIconSoundsEnabled). */
export function getIconSoundsEnabled(): boolean {
if (typeof window === "undefined") return true;
load();
return enabled ?? true;
}
 
/** Turns icon sounds on or off for this browser. The choice is remembered. */
export function setIconSoundsEnabled(on: boolean) {
load();
enabled = on;
try {
window.localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
} catch {
// ignore
}
listeners.forEach((l) => l());
}
 
/** Calls you back when the switch changes. Returns an unsubscribe function (handy for useSyncExternalStore). */
export function subscribeIconSounds(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
 
/**
* Plays an icon's sound. Call it from an event handler (a click, a tap, a key press): browsers only let a page
* make sound after the visitor has interacted with it.
*
* - speed: match the icon's speed prop so picture and sound stay in step.
* - volume: 0 to 1, on top of the icon's own measured level.
* - force: play even if the visitor has sounds switched off (for a button whose job is to play the sound).
*/
export function playIconScore(score: IconScore, opts: { speed?: number; volume?: number; force?: boolean } = {}) {
if (!opts.force && !getIconSoundsEnabled()) return;
if (typeof document !== "undefined" && document.hidden) return;
const c = audio();
if (!c || !master) return;
const speed = Math.min(4, Math.max(0.25, opts.speed ?? 1));
const g = c.createGain();
g.gain.value = Math.max(0, Math.min(1, (opts.volume ?? 0.9) * score.level));
g.connect(master);
score.play(c, g, (s) => s / speed);
window.setTimeout(() => g.disconnect(), (score.length / speed + 1.2) * 1000);
}
 

The note helpers

The small vocabulary every score is written in: ding, plink, blip, swish, tick, thud and puff, plus note names.

import { hiss, tone } from "./sound-engine";
 
/**
* The small building blocks every icon sound is made from, drawn with the Web Audio API (no audio files).
* `at` is always seconds from the moment the icon is triggered, and the icon sounds pass it through `t`,
* which stretches or squeezes time to match the icon's `speed` prop, so a sound stays in step with its picture.
*/
export type Time = (seconds: number) => number;
 
/** Notes, in hertz, from the C major pentatonic scale plus a few friends: bright but never shrill. */
export const N = {
C4: 261.63, E4: 329.63, G4: 392, A4: 440,
C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99, A5: 880, B5: 987.77,
C6: 1046.5, D6: 1174.66, E6: 1318.51, G6: 1567.98, A6: 1760, B6: 1975.53,
C7: 2093, D7: 2349.32, E7: 2637.02, G7: 3135.96, C8: 4186,
} as const;
 
/** A glassy bell: a pure tone with a quiet octave and a faint overtone that dies first. */
export function ding(c: AudioContext, o: AudioNode, freq: number, at: number, gain = 0.12, decay = 0.6) {
tone(c, o, { freq, at, attack: 0.004, decay, gain });
tone(c, o, { freq: freq * 2, at, attack: 0.003, decay: decay * 0.45, gain: gain * 0.28 });
tone(c, o, { freq: freq * 2.76, at, attack: 0.002, decay: decay * 0.18, gain: gain * 0.12 });
}
 
/** A bubbly note that springs up into pitch, with a tiny bright tick on top. */
export function plink(c: AudioContext, o: AudioNode, freq: number, at: number, gain = 0.1, decay = 0.13) {
tone(c, o, { freq: freq * 0.72, to: freq, glide: 0.035, at, attack: 0.003, decay, gain });
tone(c, o, { freq: freq * 3, at, attack: 0.001, decay: 0.025, gain: gain * 0.18 });
}
 
/** A short pitched blip. Give `to` to glide, and `type` for a rougher voice (triangle, sawtooth, square). */
export function blip(c: AudioContext, o: AudioNode, freq: number, at: number, gain = 0.1, decay = 0.08, to?: number, type: OscillatorType = "sine") {
tone(c, o, { freq, to, glide: decay * 0.7, at, attack: 0.003, decay, gain, type });
}
 
/** A soft sweep of air between two pitches. */
export function swish(c: AudioContext, o: AudioNode, at: number, length: number, from: number, to: number, gain = 0.05) {
hiss(c, o, { at, attack: length * 0.45, decay: length * 0.55, freq: from, to, gain, q: 0.8 });
}
 
/** A tiny dry click. */
export function tick(c: AudioContext, o: AudioNode, at: number, gain = 0.12, freq = 5000) {
hiss(c, o, { at, freq, decay: 0.012, gain, filter: "highpass" });
}
 
/** A low thump: something soft landing. */
export function thud(c: AudioContext, o: AudioNode, at: number, freq = 110, gain = 0.3, decay = 0.14) {
tone(c, o, { freq, to: freq * 0.55, glide: decay * 0.8, at, attack: 0.002, decay, gain });
hiss(c, o, { at, freq: 500, decay: 0.04, gain: gain * 0.35, filter: "lowpass" });
}
 
/** A burst of noise: a crackle, a rustle, a puff of air. */
export function puff(c: AudioContext, o: AudioNode, at: number, length = 0.08, freq = 3000, gain = 0.06, filter: BiquadFilterType = "bandpass") {
hiss(c, o, { at, attack: 0.004, decay: length, freq, gain, filter });
}
 

2 and 3. Add a sound and play it

Copy the score from the icon's page (for example bell) into components/icons/sounds/bell.ts, then play it from a handler.

"use client";
 
import { BellIcon } from "@/components/icons/bell";
import * as bell from "@/components/icons/sounds/bell";
import { playIconScore } from "@/components/icons/sound-engine";
 
export function NotificationsButton() {
return (
<button
type="button"
aria-label="Notifications"
onPointerEnter={() => playIconScore(bell)} // plays with the icon's own animation
onClick={() => playIconScore(bell)} // and again on tap (touch screens have no hover)
>
<BellIcon size={24} />
</button>
);
}

The liquid looks have a sound too

Mercury, Liquid and Glass each have two sounds in the same format as an icon's: a short layer for hovering an icon that wears the look, and a longer one for the moment it is picked. Mercury is a bead of metal rolling and ringing, Liquid is bloops and drips, Glass is a clink and a thin ring. Save the file beside sound-kit.ts. On the icons page, picking a liquid look plays its sound and turns Sound on.

import { mercury } from "@/components/icons/material-sounds";
import { playIconScore } from "@/components/icons/sound-engine";
 
// when the visitor picks the look: pouring metal, the blob landing, a ring as it sets
playIconScore(mercury.switch);
 
// while an icon wearing the look is hovered, on top of the icon's own sound
playIconScore(mercury.hover);
 
// liquid and glass work the same way: liquid.switch, liquid.hover, glass.switch, glass.hover
import { ding, plink, blip, swish, tick, thud, puff, N, type Time } from "./sound-kit";
 
/**
* The sounds of the liquid looks (Mercury, Liquid and Glass), in the same score format as every icon's sound:
* `length`, `level` and `play(c, o, t)`. Each look has two:
*
* hover a short layer that plays on top of an icon's own sound while it wears the look
* switch a longer one for the moment a look is picked
*
* With the standalone engine: playIconScore(mercury.switch) or playIconScore(mercury.hover, { volume: 0.6 }).
* Like the icon sounds, they are synthesised in the browser: no audio files.
*/
export type MaterialScore = { length: number; level: number; play: (c: AudioContext, o: AudioNode, t: Time) => void };
export type MaterialSoundName = "mercury" | "liquid" | "glass";
 
/** Mercury: a bead of liquid metal, rolling, then ringing as it sets. */
export const mercury: { hover: MaterialScore; switch: MaterialScore } = {
hover: {
length: 0.6,
level: 1.25,
play(c, o, t) {
blip(c, o, 430, t(0.0), 0.05, 0.34, 150, "triangle"); // the weight of the bead rolling
swish(c, o, t(0.02), t(0.3), 1800, 7400, 0.05); // the shine sweeping over it
tick(c, o, t(0.1), 0.06, 6500);
ding(c, o, N.E7, t(0.1), 0.05, 0.5); // metal ringing
ding(c, o, N.B6, t(0.18), 0.035, 0.45);
},
},
switch: {
length: 1.1,
level: 0.98,
play(c, o, t) {
swish(c, o, t(0.0), t(0.34), 2800, 500, 0.05); // mercury pouring
blip(c, o, 520, t(0.02), 0.06, 0.4, 120, "sine");
thud(c, o, t(0.34), 120, 0.2, 0.12); // the blob lands
swish(c, o, t(0.36), t(0.3), 2000, 9000, 0.06); // and polishes up
ding(c, o, N.E7, t(0.4), 0.08, 0.8);
ding(c, o, N.A6, t(0.46), 0.05, 0.7);
ding(c, o, N.C8, t(0.55), 0.035, 0.6);
[0.52, 0.62, 0.72].forEach((at, i) => tick(c, o, t(at), 0.04, 7000 - i * 800));
},
},
};
 
/** Liquid: a wet droplet: bloops, a slosh, a drip. */
export const liquid: { hover: MaterialScore; switch: MaterialScore } = {
hover: {
length: 0.6,
level: 1.5,
play(c, o, t) {
puff(c, o, t(0.0), 0.4, 700, 0.03, "lowpass"); // the slosh
blip(c, o, 380, t(0.02), 0.08, 0.12, 820);
blip(c, o, 520, t(0.15), 0.07, 0.1, 1000);
blip(c, o, 300, t(0.27), 0.06, 0.14, 640);
plink(c, o, N.A6, t(0.34), 0.04, 0.1);
},
},
switch: {
length: 0.95,
level: 1.39,
play(c, o, t) {
blip(c, o, 200, t(0.0), 0.1, 0.24, 720); // one big bloop
puff(c, o, t(0.02), 0.5, 600, 0.04, "lowpass");
thud(c, o, t(0.26), 100, 0.12, 0.1);
[[0.3, N.E6], [0.43, N.C6], [0.56, N.G5]].forEach(([at, f]) => plink(c, o, f as number, t(at as number), 0.07, 0.14)); // drips
ding(c, o, N.A6, t(0.7), 0.03, 0.3);
},
},
};
 
/** Glass: a clink and a long, thin ring. */
export const glass: { hover: MaterialScore; switch: MaterialScore } = {
hover: {
length: 0.5,
level: 1.38,
play(c, o, t) {
tick(c, o, t(0.0), 0.08, 6500);
ding(c, o, N.G7, t(0.01), 0.06, 0.45);
ding(c, o, N.C8, t(0.05), 0.03, 0.3);
swish(c, o, t(0.02), t(0.2), 5000, 9500, 0.02);
},
},
switch: {
length: 1.2,
level: 1.2,
play(c, o, t) {
tick(c, o, t(0.0), 0.1, 6000); // two glasses touching
ding(c, o, N.E7, t(0.01), 0.07, 0.6);
tick(c, o, t(0.14), 0.1, 7000);
ding(c, o, N.G7, t(0.15), 0.06, 0.7);
ding(c, o, N.C8, t(0.3), 0.05, 1.0); // the long shimmering ring
ding(c, o, N.G7, t(0.34), 0.035, 0.9);
swish(c, o, t(0.3), t(0.5), 4500, 10000, 0.015);
},
},
};
 
export const MATERIAL_SOUNDS = { mercury, liquid, glass } as const;
 

Patterns you will want

Match the icon's speed

// Keep picture and sound in step: pass the same speed to both.
<BellIcon speed={1.5} />
playIconScore(bell, { speed: 1.5 });
 
// Quieter or louder, on top of the icon's own measured level:
playIconScore(bell, { volume: 0.5 });

Give visitors a sound switch

"use client";
 
import { useSyncExternalStore } from "react";
import { getIconSoundsEnabled, setIconSoundsEnabled, subscribeIconSounds } from "@/components/icons/sound-engine";
 
export function SoundSwitch() {
const on = useSyncExternalStore(subscribeIconSounds, getIconSoundsEnabled, () => true);
return (
<button type="button" aria-pressed={on} onClick={() => setIconSoundsEnabled(!on)}>
Sound {on ? "on" : "off"}
</button>
);
}
// The choice is remembered in the browser. playIconScore() stays silent while it is off.

Use it without React

// No React needed: a score is just a module with length, level and play().
import * as coin from "./sounds/coin";
import { playIconScore } from "./sound-engine";
 
document.querySelector("#pay")?.addEventListener("click", () => playIconScore(coin));

Respect reduced motion

// Optional: stay quiet for visitors who asked the system for less.
const quiet = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!quiet) playIconScore(bell);

Good habits

  • Only after a gesture

    Browsers block sound until the visitor has clicked, tapped or pressed a key. Play from handlers, never on page load.

  • Touch has no hover

    Play on pointer enter for mouse users, and on click or tap for everyone. Both are shown above.

  • Always a switch

    Sound is a bonus, never the only signal. Let visitors turn it off, and keep volume modest.

  • Quiet when hidden

    The engine stays silent while the tab is hidden, and it respects the visitor's on/off choice.

Questions

Are the sounds free?
The sound engine, the note helpers and this guide are free. An icon's sound score comes with the icon: free for the 75 free icons, and part of Pro for the other 125. You can hear every icon's sound on its page and on the icons page first.
Do the icons need sound to work?
No. Sound is optional and separate: the animated icon is one file, and the sound is a second, optional file per icon. Without it, nothing is loaded or played.
Why do I hear nothing the first time?
Browsers only let a page make sound after the visitor has interacted with it (a click, a tap or a key press). Call playIconScore from an event handler, and the first interaction wakes the audio engine.
Does it work on iPhones and Android?
Yes, after a tap. Hover does not exist on touch screens, so also play the sound on click. On iPhones, sounds follow the ringer volume.
How big is it?
The engine is about 4 KB of TypeScript. Each icon's score is under 1 KB. There are no audio files, so nothing is downloaded.
Can I change a sound?
Yes. A score is a short list of notes placed on moments of the icon's animation, written with a handful of helpers (ding, plink, blip, swish, tick, thud, puff). Edit the numbers, or write your own.
Is it accessible?
Sound should never be the only signal. Keep every action visible, give visitors a switch (setIconSoundsEnabled), do not play anything on page load, and keep volumes modest.

Ready to pick sounds? Browse all 200 icons, switch Sound on and hover.