How to Build an Audio Visualizer in React
Have you ever wondered how those mesmerizing audio visualizers work? Bars bouncing to the beat and frequencies dancing across the screen? In this tutorial, we’ll break down how to build your own audio visualizer in React using the Canvas API and the powerful Web Audio API. Whether you're building a music app, podcast tool, or just want to experiment with sound and visuals. This is a guide to explore real-time audio processing in the browser.

Jordan Wu
15 min read·Posted

Table of Contents
Audio Visualizer
An audio visualizer is a dynamic graphical representation of sound. It takes data in real time of an audio input then translates properties of the audio. Such as frequency, amplitude, and rhythm into animated visual patterns like bars, waves, or shapes that move and change with the sound. Audio visualizers are often used in music players, live DJ sets, streaming platforms, or interactive web applications to enhance the listening experience by making sound visually engaging. In web development, they’re commonly built using technologies like the Web Audio API, Canvas, or WebGL combined with JavaScript frameworks like React.
Creating the Audio Visualizer in React
We will be using Canvas API for displaying the visualizer and Web Audio API for getting the sound audio data in real time. This will be a bar audio visualizer where vertical bars move in real time based on the frequencies and volume levels of the audio being played. Each bar typically represents a specific frequency range, and the height of the bar corresponds to the amplitude or energy in that frequency.
Labeling the Axes: Frequency (Hz) and Amplitude (dB)
Frequency refers to the number of complete sound wave cycles that occur per second and is measured in hertz (Hz). For example, a frequency of 1 Hz means one cycle per second. The human ear can typically detect frequencies ranging from 20 Hz to 20,000 Hz. Frequency determines the pitch of a sound. Lower frequencies produce deeper tones. Higher frequencies create higher pitched sounds. A song is essentially a combination of many sounds playing simultaneously, each with its own frequency. By analyzing this data, you can determine where each sound sits within the audio spectrum.
In a bar audio visualizer, frequency is usually mapped to the x-axis, with bars representing different ranges from low to high pitch. In our case we will only cover the frequency that a human ear can detect from 20 Hz to 20,000 Hz.
const FREQUENCY_LABELS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, 20000]
Amplitude measures the strength or intensity of a sound wave and directly affects how loud or soft a sound appears to our ears. It is commonly expressed in decibels (dB), a logarithmic unit that quantifies sound intensity relative to a reference level. Because the decibel scale is logarithmic, an increase of 10 dB represents a tenfold increase in sound intensity, meaning even small changes in dB can reflect large differences in perceived loudness.
In a bar audio visualizer, amplitude is typically represented on the y-axis, where taller bars correspond to higher amplitudes and therefore louder sounds. In our case, we will label the maximum amplitude at 0 dB and the minimum at -60 dB.
const DB_VALUES = [0, -10, -20, -30, -40, -50, -60]
Our ears perceive different frequencies with varying sensitivity, meaning sounds at certain pitches may seem louder or softer even if their physical amplitude is the same. This phenomenon, known as equal loudness perception. The human ear is most sensitive to mid-range frequencies around 2,000 to 5,000 Hz and less sensitive to very low or very high frequencies. Because of this, audio measurements often use weighting filters, such as A-weighting. To adjust levels and better reflect how we actually hear sound across the frequency spectrum.
The frequency label will logarithmically. Human perception of pitch is roughly logarithmic, meaning each octave (doubling of frequency) is perceived as an equal step in pitch.
Analyser Node
The AnalyserNode is part of the Web Audio API and provides real-time insight into the audio playing in a web application. It sits in the audio processing graph and lets you examine the sound’s frequency and waveform data as it plays. This makes it especially useful for creating visualizations like bar or wave visualizers that respond dynamically to audio input.
fftSize
fftSize
is a property of the AnalyserNode
in the Web Audio API that determines the size of the Fast Fourier Transform (FFT) used to analyze the audio signal. It must be a power of 2 (like 32, 64, 128, up to 32768) and controls the frequency resolution of the analysis. A larger fftSize gives you more frequency bins and finer detail in the frequency spectrum, but it also increases the amount of data and can make the visualizer respond a bit slower. Smaller values update more quickly but offer less detail. The default value is 2048.
frequencyBinCount
frequencyBinCount
is a property of the AnalyserNode
that represents half of the fftSize
and defines how many frequency data points are available for analysis. Each of these points, or "bins," corresponds to a specific range of frequencies in the audio signal, with their values indicating the amplitude of those frequencies. The total frequency range spans from 0 Hz up to half the audio sample rate (usually around 22,050 Hz for standard 44.1 kHz audio). This property is essential for creating accurate and detailed visual representations of an audio signal's frequency content.
minDecibels
The minDecibels
property of the AnalyserNode
defines the minimum power level (in decibels) that will be considered when analyzing audio frequencies. It sets the lower bound of the dynamic range for frequency data, meaning any signal below this level is effectively treated as silence in the visual output. By default, it's set to -100 dB, but you can adjust it to control how quiet sounds are represented in your visualization.
maxDecibels
The maxDecibels
property of the AnalyserNode
sets the upper limit of the audio signal's power level in decibels for frequency analysis. It defines the loudest value that will be represented in the frequency data. By default, it's set to -30 dB, meaning any signal stronger than this will be capped at that level in visualizations. Adjusting maxDecibels lets you fine-tune how loud sounds appear in your audio visualizer.
smoothingTimeConstant
The smoothingTimeConstant
property of the AnalyserNode
controls how much the audio data is smoothed over time, helping to create a more fluid and less jittery visual experience. It’s a value between 0 and 1, where 0 means no smoothing (fast, sharp changes) and 1 means heavy smoothing (slow, gradual changes). This is especially useful for making visualizations like bar graphs look more natural and less erratic when audio levels fluctuate rapidly.
getByteFrequencyData
The getByteFrequencyData
is a method of the AnalyserNode
that copies the current frequency data into a Uint8Array. This data represents the amplitude of different frequency bands as unsigned 8-bit integers ranging from 0 to 255. It’s commonly used to get real-time frequency information for creating audio visualizations like bar graphs, where each value corresponds to the strength of a specific frequency range.
Audio Visualizer React Component
The implementation of the component will be using react-use-audio-player which uses howler.js to get the audio data. The reason for using this library is to handle one shared audio context within the application by using useAudioPlayerContext
it returns a shared instance of an AudioPlayer. It's designed to provide access to a shared audio resource across multiple components within your application.
import { useRef, useEffect, useState } from 'react'
import { useAudioPlayerContext } from 'react-use-audio-player'
import clsx from 'clsx'
type AudioVisualizerProps = {
height?: number | string
width?: number | string
fftSize?: 4096
maxDecibels?: number
minDecibels?: number
smoothingTimeConstant?: number
}
const FREQUENCY_LABELS = [
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 2000, 3000, 4000, 5000, 6000,
7000, 8000, 9000, 10000, 20000,
]
const MIN_FREQUENCY = 20
const MAX_FREQUENCY = 20000
const DB_VALUES = [0, -10, -20, -30, -40, -50, -60]
const BARS = 36
function AudioVisualizer(props: AudioVisualizerProps) {
const {
width = '100%',
height = '100%',
fftSize = 4096,
maxDecibels = 0,
minDecibels = -60,
smoothingTimeConstant = 0.4,
} = props
const [responsiveHeight, setResponsiveHeight] = useState<null | number>(null)
const [responsiveWidth, setResponsiveWidth] = useState<null | number>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const { player } = useAudioPlayerContext()
const audio = player?._sounds[0]?._node
useEffect(() => {
const container = document.getElementById('audio-visualizer')
if (!container) return
const parentElement = container.parentElement
if (!parentElement) return
const { width: initialWidth, height: initialHeight } = parentElement.getBoundingClientRect()
if (width === '100%') {
setResponsiveWidth(initialHeight)
}
if (height === '100%') {
setResponsiveHeight(initialWidth)
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width: newWidth, height: newHeight } = entry.contentRect
if (width === '100%') {
setResponsiveWidth(newWidth)
}
if (height === '100%') {
setResponsiveHeight(newHeight)
}
}
})
resizeObserver.observe(parentElement)
return () => {
resizeObserver.disconnect()
}
}, [setResponsiveHeight, setResponsiveWidth])
useEffect(() => {
if (!canvasRef.current) return
const canvas = canvasRef.current
const canvasCtx = canvas.getContext('2d')
if (audio) {
audio.crossOrigin = 'anonymous'
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
const ctx = new AudioContext()
const analyserNode = ctx.createAnalyser()
analyserNode.fftSize = fftSize
analyserNode.minDecibels = minDecibels
analyserNode.maxDecibels = maxDecibels
analyserNode.smoothingTimeConstant = smoothingTimeConstant
const source = ctx.createMediaElementSource(audio)
source.connect(analyserNode)
source.connect(ctx.destination)
const dataArray = new Uint8Array(analyserNode.frequencyBinCount)
const nyquistFrequency = ctx.sampleRate / 2
const frequencyPerBin = nyquistFrequency / analyserNode.frequencyBinCount
const startBin = Math.floor(MIN_FREQUENCY / frequencyPerBin)
const endBin = Math.ceil(MAX_FREQUENCY / frequencyPerBin)
const previousValues = new Float32Array(endBin - startBin)
let animationFrameId: number
function draw() {
if (!canvasRef.current || !analyserNode) return
if (!canvasCtx) return
analyserNode.getByteFrequencyData(dataArray)
const width = canvas.width
const height = canvas.height
canvasCtx.fillStyle = 'rgb(23, 23, 23)'
canvasCtx.fillRect(0, 0, width, height)
canvasCtx.fillStyle = 'white'
canvasCtx.strokeStyle = 'white'
drawFrequencyLabels(canvasCtx)
drawDbLabels(canvasCtx)
for (let bar = 0; bar < BARS; bar++) {
const { averageValue, frequency } = getFrequencyAndRawValue(dataArray, bar, frequencyPerBin)
const currentValue = (averageValue + aWeighting(frequency)) / 255
if (currentValue > previousValues[bar]) {
previousValues[bar] = currentValue // rise immediately
} else {
previousValues[bar] *= 0.994 // decay slowly
}
const smoothedValue = previousValues[bar]
const minHeight = height * 0.001
const barHeight = Math.max(smoothedValue * height, minHeight)
const gap = 4
const totalWidth = canvas.width
const availableWidth = totalWidth - gap * (BARS - 1)
const barWidth = availableWidth / BARS
const x = bar * (barWidth + gap)
canvasCtx.fillStyle = 'white'
canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
}
animationFrameId = requestAnimationFrame(draw)
}
animationFrameId = requestAnimationFrame(draw)
return () => {
cancelAnimationFrame(animationFrameId)
source?.disconnect()
analyserNode?.disconnect()
if (ctx.state !== 'closed') {
ctx.close()
}
}
}
}, [audio])
return (
<canvas
id="audio-visualizer"
className={clsx(
width === '100%' ? 'w-full' : `w-[${responsiveWidth || width}px]`,
height === '100%' ? 'h-full' : `h-[${responsiveHeight || height}px]`
)}
ref={canvasRef}
width={responsiveWidth || width}
height={responsiveHeight || height}
style={{
aspectRatio: 'unset',
}}
/>
)
}
function getFrequencyAndRawValue(dataArray: Uint8Array, bar: number, frequencyPerBin: number) {
let averageValue
let frequency
let startBinIndex
let endBinIndex
let bins
switch (bar) {
case 0:
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7:
case 8:
startBinIndex = bar
endBinIndex = bar
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 9:
startBinIndex = 9
endBinIndex = 11
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 10:
startBinIndex = 12
endBinIndex = 14
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 11:
startBinIndex = 15
endBinIndex = 17
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 12:
startBinIndex = 18
endBinIndex = 22
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 13:
startBinIndex = 23
endBinIndex = 27
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 14:
startBinIndex = 28
endBinIndex = 32
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 15:
startBinIndex = 33
endBinIndex = 37
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
case 16:
startBinIndex = 38
endBinIndex = 46
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 17:
startBinIndex = 47
endBinIndex = 55
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 18:
startBinIndex = 56
endBinIndex = 64
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 19:
startBinIndex = 65
endBinIndex = 74
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 20:
startBinIndex = 75
endBinIndex = 87
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 21:
startBinIndex = 88
endBinIndex = 115
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 22:
startBinIndex = 116
endBinIndex = 143
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 23:
startBinIndex = 144
endBinIndex = 171
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 24:
startBinIndex = 172
endBinIndex = 212
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 25:
startBinIndex = 213
endBinIndex = 255
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 26:
startBinIndex = 256
endBinIndex = 314
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 27:
startBinIndex = 315
endBinIndex = 400
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 28:
startBinIndex = 401
endBinIndex = 482
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 29:
startBinIndex = 483
endBinIndex = 565
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 30:
startBinIndex = 566
endBinIndex = 667
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 31:
startBinIndex = 668
endBinIndex = 777
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 32:
startBinIndex = 778
endBinIndex = 888
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 33:
startBinIndex = 889
endBinIndex = 1162
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 34:
startBinIndex = 1163
endBinIndex = 1436
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
case 35:
startBinIndex = 1437
endBinIndex = 1707
bins = dataArray.slice(startBinIndex, endBinIndex + 1)
averageValue = average(bins)
break
default:
throw new Error('Invalid bar there should be 36 bars')
}
frequency =
(frequencyPerBin * (endBinIndex + 1) - frequencyPerBin * startBinIndex) / 2 + frequencyPerBin * startBinIndex
return { averageValue, frequency }
}
function drawFrequencyLabels(canvasCtx: CanvasRenderingContext2D) {
canvasCtx.fillStyle = 'white'
canvasCtx.globalAlpha = 1
canvasCtx.setLineDash([])
const frequencyBands = generateOctaveBands()
const minLabelRange = frequencyBands[0].ctr
const maxLabelRange = frequencyBands[frequencyBands.length - 1].ctr
FREQUENCY_LABELS.map((frequency) => {
const label = frequency >= 1000 ? frequency / 1000 + 'kHz' : frequency + 'Hz'
const posX = map(
Math.log2(frequency),
Math.log2(minLabelRange),
Math.log2(maxLabelRange),
1 / frequencyBands.length / 2,
1 - 1 / frequencyBands.length / 2
)
canvasCtx.beginPath()
canvasCtx.lineTo(posX * canvasCtx.canvas.width, canvasCtx.canvas.height)
canvasCtx.lineTo(posX * canvasCtx.canvas.width, 0)
canvasCtx.stroke()
canvasCtx.fillText(label, posX * canvasCtx.canvas.width, canvasCtx.canvas.height)
})
canvasCtx.setLineDash([])
canvasCtx.textAlign = 'start'
canvasCtx.textBaseline = 'alphabetic'
}
function aWeighting(frequency: number): number {
const f2 = frequency * frequency
const f4 = f2 * f2
const ra_num = 12194 ** 2 * f4
const ra_den = (f2 + 20.6 ** 2) * Math.sqrt((f2 + 107.7 ** 2) * (f2 + 737.9 ** 2)) * (f2 + 12194 ** 2)
return 20 * Math.log10(ra_num / ra_den)
}
function drawDbLabels(canvasCtx: CanvasRenderingContext2D) {
canvasCtx.setLineDash([])
canvasCtx.textBaseline = 'alphabetic'
DB_VALUES.map((dbValue) => {
const label = `${dbValue}dB`
const posY = map(ascale(10 ** (dbValue / 20)), 0, 1, canvasCtx.canvas.height, 0)
if (posY >= 0) {
canvasCtx.beginPath()
canvasCtx.lineTo(0, posY)
canvasCtx.lineTo(canvasCtx.canvas.width, posY)
canvasCtx.stroke()
canvasCtx.textAlign = 'end'
canvasCtx.fillText(label, canvasCtx.canvas.width, posY + 10)
}
})
canvasCtx.setLineDash([])
canvasCtx.textAlign = 'start'
canvasCtx.textBaseline = 'alphabetic'
}
function map(frequency: number, min: number, max: number, targetMin: number, targetMax: number) {
return ((frequency - min) / (max - min)) * (targetMax - targetMin) + targetMin
}
function generateOctaveBands(
bandsPerOctave = 12,
lowerNote = 4,
higherNote = 123,
detune = 0,
tuningFreq = 440,
bandwidth = 0.5
) {
const tuningNote = isFinite(Math.log2(tuningFreq)) ? Math.round((Math.log2(tuningFreq) - 4) * 12) * 2 : 0,
root24 = 2 ** (1 / 24),
c0 = tuningFreq * root24 ** -tuningNote, // ~16.35 Hz
groupNotes = 24 / bandsPerOctave
let bands = []
for (let i = Math.round((lowerNote * 2) / groupNotes); i <= Math.round((higherNote * 2) / groupNotes); i++) {
bands.push({
lo: c0 * root24 ** ((i - bandwidth) * groupNotes + detune),
ctr: c0 * root24 ** (i * groupNotes + detune),
hi: c0 * root24 ** ((i + bandwidth) * groupNotes + detune),
})
}
return bands
}
function ascale(dbValue: number) {
const minDecibels = -60
const maxDecibels = 0
return map(20 * Math.log10(dbValue), minDecibels, maxDecibels, 0, 1)
}
function average(arr: Uint8Array | number[]) {
if (!arr.length) return 0
const sum = Array.from(arr).reduce((acc, val) => acc + val, 0)
return sum / arr.length
}
export default AudioVisualizer
- There will only be
36
bars in the audio visualizer. - This component uses ResizeObserver to automatically resize the canvas HTML element whenever the browser window size changes.
- The function
getFrequencyAndRawValue
analyzes frequency data to calculate the average amplitude for a specific frequency band (bar) and identifies the corresponding frequency for that band. This frequency is then used withaWeighting
to apply a weighting that reflects how the human ear perceives different frequencies, emphasizing some more than others.

Check out Audio Visualizer Playground
Summary
That’s everything you need to build a basic audio visualizer React component! It’s a fun and powerful way to translate frequency and amplitude into animated visual bars. From here, you can customize and expand the setup to create your own unique way of visualizing music. Personally, I wanted to design a visualizer that gives more control over how specific sounds like drums, vocals, and melodies are displayed. Imagine highlighting each element of a track visually, based on how it sounds. Check out the visualizer I built below!