clickable.
The click sound this site makes, synthesized from four milliseconds of filtered noise, with a little jitter so no two presses sound the same.
live component, try it
what to expect.
how it works.
Every press on this site is four milliseconds of white noise through a narrow bandpass filter, which is what a mechanical click acoustically is. Synthesizing it costs no network bytes and lets each kind of interaction pick its own timbre from one primitive instead of shipping a sprite sheet of near-identical samples. The detail that matters is the jitter. Without it, every press is bit for bit the same and the ear catches the repetition by the third, because no real mechanism ever makes exactly the same sound twice. The ticks under the pad plot where the last few presses landed, so the jitter is visible with the sound off.
the component.
export type FeedbackKind = "tick" | "click" | "close" | "confirm";
export interface Burst {
frequency: number;
q: number;
gain: number;
decay: number;
offset?: number;
}
export const BURSTS: Record<FeedbackKind, readonly Burst[]> = {
tick: [{ frequency: 4200, q: 9, gain: 0.33, decay: 20, offset: 0 }],
click: [{ frequency: 2600, q: 7, gain: 0.52, decay: 30, offset: 0 }],
close: [{ frequency: 1800, q: 6, gain: 0.42, decay: 34, offset: 0 }],
confirm: [
{ frequency: 2600, q: 7, gain: 0.45, decay: 28, offset: 0 },
{ frequency: 3900, q: 7, gain: 0.45, decay: 28, offset: 0.07 },
],
};
// Past ~6ms a click stops sounding like a click.
const BURST_SECONDS = 0.004;
// Spread of per-play frequency jitter; without it repeated clicks sound identical.
const JITTER = 0.3;
const MIN_INTERVAL_MS = 30;
let context: AudioContext | null = null;
let lastPlayAt = 0;
const buffers = new Map<number, AudioBuffer>();
function bufferFor(ctx: AudioContext, decay: number): AudioBuffer {
const cached = buffers.get(decay);
if (cached) return cached;
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * BURST_SECONDS), ctx.sampleRate);
const samples = buffer.getChannelData(0);
for (let i = 0; i < samples.length; i++) {
// White noise under an exponential decay envelope.
samples[i] = (Math.random() * 2 - 1) * Math.exp(-i / decay);
}
buffers.set(decay, buffer);
return buffer;
}
function ensureContext(): AudioContext | null {
if (typeof window === "undefined") return null;
if (!context) {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return null;
context = new Ctor();
}
// Backgrounded tabs suspend the context and it does not resume on its own.
if (context.state === "suspended") void context.resume();
return context;
}
function startBurst(ctx: AudioContext, burst: Burst, jitter: number): void {
const source = ctx.createBufferSource();
source.buffer = bufferFor(ctx, burst.decay);
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = burst.frequency * (1 + (Math.random() - 0.5) * jitter);
filter.Q.value = burst.q;
const gain = ctx.createGain();
gain.gain.value = burst.gain;
source.connect(filter).connect(gain).connect(ctx.destination);
source.onended = () => {
source.disconnect();
filter.disconnect();
gain.disconnect();
};
source.start(ctx.currentTime + (burst.offset ?? 0));
}
function claimPlay(): AudioContext | null {
const now = Date.now();
if (now - lastPlayAt < MIN_INTERVAL_MS) return null;
const ctx = ensureContext();
if (!ctx) return null;
lastPlayAt = now;
return ctx;
}
export function playSound(kind: FeedbackKind): void {
const ctx = claimPlay();
if (!ctx) return;
for (const burst of BURSTS[kind]) startBurst(ctx, burst, JITTER);
}
export function playBurst(burst: Burst, jitter = JITTER): void {
const ctx = claimPlay();
if (!ctx) return;
startBurst(ctx, burst, jitter);
}