Audio Visualizer in React: Web Audio API + Canvas = Waveform Magic
Build a real-time audio visualizer in React using Web Audio API and Canvas. Frequency bars, waveforms, and animation loops — all wired up with hooks.
Why Build an Audio Visualizer at All?
Audio visualizers are one of those things that look impossible until you actually sit down and build one — then you realize it's maybe 80 lines of JavaScript. The Web Audio API has been around since Chrome 14 back in 2012, but most React developers have never touched it. That's a shame, because it exposes genuinely interesting data about sound that you can paint onto a canvas in real time.
Honestly, a working visualizer is also a great way to learn requestAnimationFrame, the AnalyserNode, and typed arrays in one go. You're not just learning audio; you're learning how browsers handle continuous rendering loops — which is the same mental model you need for games, data dashboards, and any canvas-heavy UI.
So here's what we're building: a React component that accepts an audio source (a file or mic input), runs it through the Web Audio API's analyser, reads frequency data on every frame, and draws bars onto a <canvas> element. No third-party audio library required. Just the browser and React.
Worth noting: this approach pairs really well with any dark or neon-heavy UI theme. If you're already using glassmorphism components or something from the cyberpunk style hub, a glowing waveform drops right in.
Setting Up the Web Audio API Context
Everything in the Web Audio API starts with an AudioContext. Think of it as the audio engine — it manages sample rate, timing, and the graph of nodes that audio flows through. You create one, connect a source to it, wire up an analyser, and connect that to the destination (your speakers).
In React you want to create the AudioContext lazily — not on module load, not in a useEffect that runs twice in StrictMode. Create it on user gesture. Browsers require that since roughly 2018, and they'll suspend the context otherwise.
import { useRef, useEffect } from 'react';
export function useAudioAnalyser(stream: MediaStream | null) {
const analyserRef = useRef<AnalyserNode | null>(null);
const contextRef = useRef<AudioContext | null>(null);
useEffect(() => {
if (!stream) return;
const ctx = new AudioContext({ sampleRate: 44100 });
const analyser = ctx.createAnalyser();
analyser.fftSize = 256; // 128 frequency buckets
const source = ctx.createMediaStreamSource(stream);
source.connect(analyser);
// Do NOT connect to ctx.destination unless you want echo
contextRef.current = ctx;
analyserRef.current = analyser;
return () => {
source.disconnect();
ctx.close();
};
}, [stream]);
return analyserRef;
}The fftSize is critical. It must be a power of 2 between 32 and 32768. Setting it to 256 gives you 128 frequency buckets (always fftSize / 2). More buckets means more detail, but also more CPU. For most visualizers, 256 or 512 is the sweet spot.
Quick aside: createMediaStreamSource works for mic input. For a file, use createMediaElementSource on an <audio> element instead. The rest of the pipeline stays identical.
Reading Frequency Data and Painting to Canvas
The analyser exposes two methods you'll actually use: getByteFrequencyData and getByteTimeDomainData. The first gives you a bar-graph-style frequency spectrum. The second gives you the raw waveform shape. Both fill a Uint8Array with values from 0–255.
The rendering loop lives in a useEffect that starts after the analyser is ready. You call requestAnimationFrame, read the data, clear the canvas, draw, and schedule the next frame. Simple. The whole loop is maybe 30 lines.
import { useEffect, useRef } from 'react';
export function AudioVisualizer({ analyserRef }: { analyserRef: React.RefObject<AnalyserNode | null> }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const analyser = analyserRef.current;
if (!canvas || !analyser) return;
const ctx = canvas.getContext('2d')!;
const bufferLength = analyser.frequencyBinCount; // 128
const dataArray = new Uint8Array(bufferLength);
let rafId: number;
const draw = () => {
rafId = requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
const hue = (i / bufferLength) * 280; // purple → cyan
ctx.fillStyle = `hsl(${hue}, 80%, 55%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
return () => cancelAnimationFrame(rafId);
}, [analyserRef]);
return <canvas ref={canvasRef} width={600} height={200} className="w-full rounded-xl" />;
}That hsl trick maps each bar to a different hue automatically — no hardcoded color arrays, no gradient setup. Change the hue range to 0–60 for an orange-to-yellow fire look, or fix it at a single value for a monochrome vibe.
In practice, canvas.width and canvas.height are the internal pixel buffer dimensions, not the CSS display size. Always set them explicitly or you'll get blurry output on HiDPI screens. For retina displays, multiply by window.devicePixelRatio and scale the context: ctx.scale(dpr, dpr).
Hooking Up a Microphone or File Input
There are two entry points for audio: the user's microphone via getUserMedia, and an <audio> or <video> element playing a file. Both are a bit different in how you feed them into the API.
For mic input, you need explicit user permission and it returns a MediaStream. Wrap the permission request in a button click handler — never call getUserMedia on page load.
function MicButton({ onStream }: { onStream: (s: MediaStream) => void }) {
const handleClick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
onStream(stream);
} catch (err) {
console.error('Mic permission denied', err);
}
};
return (
<button onClick={handleClick} className="px-4 py-2 rounded-lg bg-violet-600 text-white">
Start Mic
</button>
);
}For file-based audio, the trick is using createMediaElementSource on a plain <audio> element. One catch: you can only call createMediaElementSource once per element — calling it again throws. So store the source node in a ref.
const audioRef = useRef<HTMLAudioElement>(null);
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const handlePlay = () => {
const audio = audioRef.current!;
const ctx = new AudioContext();
if (!sourceRef.current) {
sourceRef.current = ctx.createMediaElementSource(audio);
}
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
sourceRef.current.connect(analyser);
analyser.connect(ctx.destination); // You DO want output here
// ... then start the canvas loop
};Look, the biggest footgun here is forgetting to connect the analyser back to ctx.destination when you're playing a file. With mic input you skip that because you don't want to hear yourself. With a music file you absolutely want speakers on.
Adding a Waveform Oscilloscope Mode
Frequency bars are cool, but the oscilloscope waveform — that continuous line showing the raw audio signal — is often more visually striking. You switch from getByteFrequencyData to getByteTimeDomainData and draw a path instead of rectangles. That's basically the whole change.
const drawWaveform = () => {
rafId = requestAnimationFrame(drawWaveform);
analyser.getByteTimeDomainData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineWidth = 2;
ctx.strokeStyle = '#a78bfa'; // violet-400
ctx.beginPath();
const sliceWidth = canvas.width / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0; // normalize to 0–2
const y = (v / 2) * canvas.height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.lineTo(canvas.width, canvas.height / 2);
ctx.stroke();
};The / 128.0 normalization converts the 0–255 byte range to 0–2, then you halve it to get 0–1 and multiply by canvas height. When silence plays, dataArray is all 128s, so v is exactly 1.0 and the line sits dead-center. That's expected behavior.
You can toggle between modes with a bit of state — const [mode, setMode] = useState<'bars' | 'wave'>('bars') — and branch inside the loop. Or build two separate draw functions and swap which one requestAnimationFrame calls. Either way, no re-mounting needed.
One more thing — if you want that trailing ghost effect where old frames fade out instead of being cleared, replace ctx.clearRect with a semi-transparent fill: ctx.fillStyle = 'rgba(0,0,0,0.1); ctx.fillRect(0, 0, canvas.width, canvas.height). You get a motion blur look for free. It pairs especially well with the kind of neon aesthetics you'd find in the cyberpunk or vaporwave component sets.
Performance, Cleanup, and HiDPI
A canvas animation loop running at 60fps is fine. Two of them, with large fftSize values, on a low-end mobile device — less fine. A few things you can do: lower fftSize to 128 (64 buckets, noticeably cheaper), use smoothingTimeConstant on the analyser to average frames (analyser.smoothingTimeConstant = 0.8), or throttle the loop yourself with a timestamp check.
let lastTime = 0;
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
const draw = (timestamp: number) => {
rafId = requestAnimationFrame(draw);
if (timestamp - lastTime < FRAME_INTERVAL) return;
lastTime = timestamp;
// ... actual draw code
};
requestAnimationFrame(draw);For HiDPI (retina) displays, add this before your draw loop starts:
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);Cleanup matters more than most devs realize with the Web Audio API. AudioContext instances hold onto OS-level audio resources. Always call ctx.close() in your useEffect cleanup function, and cancelAnimationFrame on any pending RAF. If you've grabbed a MediaStream, also iterate stream.getTracks().forEach(t => t.stop()). Skipping any of these causes subtle memory leaks that only show up after a user navigates back and forth a few times.
That said, if you want something production-ready with a polished design without building all the canvas UI from scratch, the Empire UI component library is worth exploring — the gradient generator and other tools there can give you color palettes that work well with audio visualizer UIs.
Putting It All Together: the Full Component
Here's a minimal but complete AudioVisualizer React component that wires mic input, canvas rendering, and proper cleanup into one file you can actually drop into a project.
import { useState, useRef, useEffect } from 'react';
export default function AudioVisualizer() {
const [active, setActive] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cleanupRef = useRef<() => void>(() => {});
const start = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
let rafId: number;
const draw = () => {
rafId = requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);
const barW = (canvas.offsetWidth / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const h = (dataArray[i] / 255) * canvas.offsetHeight;
ctx.fillStyle = `hsl(${(i / bufferLength) * 260 + 200}, 80%, 60%)`;
ctx.fillRect(x, canvas.offsetHeight - h, barW, h);
x += barW + 1;
}
};
draw();
setActive(true);
cleanupRef.current = () => {
cancelAnimationFrame(rafId);
source.disconnect();
audioCtx.close();
stream.getTracks().forEach(t => t.stop());
setActive(false);
};
};
useEffect(() => () => cleanupRef.current(), []);
return (
<div className="flex flex-col gap-4 p-6 bg-neutral-950 rounded-2xl">
<canvas ref={canvasRef} className="w-full h-40 rounded-xl bg-neutral-900" />
<div className="flex gap-2">
{!active && (
<button onClick={start} className="px-4 py-2 bg-violet-600 rounded-lg text-white text-sm">
Start Mic
</button>
)}
{active && (
<button onClick={() => cleanupRef.current()} className="px-4 py-2 bg-red-600 rounded-lg text-white text-sm">
Stop
</button>
)}
</div>
</div>
);
}This is deliberately minimal. You'd want to add error handling around the getUserMedia call, a loading state during the permission prompt, and probably a ResizeObserver to re-scale the canvas when the container resizes. But as a starting point, this runs clean.
Honestly, the most common mistake I see developers make is running the animation loop even when the component is hidden or unmounted. The useEffect cleanup on unmount handles that here, but if you're toggling visibility with CSS display: none rather than conditional rendering, you'll still be burning CPU. Prefer conditional rendering for anything with an animation loop.
FAQ
No. The native Web Audio API gives you everything you need — AnalyserNode handles the FFT math. Tone.js is worth adding if you need scheduling, synthesis, or effects chains, but for visualization it's overkill.
Browsers block AudioContext creation until a user gesture (click, tap, keypress). Create the context inside an event handler, not in a useEffect or module-level code.
Frequency data gives you a bar-per-frequency breakdown (the FFT spectrum). Time domain gives you the raw waveform shape at that instant. Use frequency for equalizer bars, time domain for the oscilloscope line.
Use audioCtx.createMediaElementSource(audioEl) instead of createMediaStreamSource. Connect it to the analyser and then to audioCtx.destination so audio still plays through speakers.