Handling Pause and Stop States
Pausing a recording requires minimal code, but the trickiest part is designing the UI around it. A key detail: pausing the recording doesn’t pause the animation, so the visualization needs its own stop logic. The recorder’s state property tells us whether we’re still capturing audio, which lets us decide whether to keep adding bars.
See the Pen [15. Pausing a Recording](https://codepen.io/smashingmag/pen/BamgQEP) by Jhey.
Here, the updated toggle handler checks the state before acting:
const RECORDING = recorder.state === 'recording'
// Pause or resume recorder based on state.
TOGGLE.style.setProperty('--active', RECORDING ? 0 : 1)
timeline[RECORDING ? 'pause' : 'play']()
recorder[RECORDING ? 'pause' : 'resume']()
And inside the reporting function, we only push new bars while the recorder is actively running:
REPORT = () => {
if (recorder && recorder.state === 'recording') {
One optimization worth trying: remove the REPORT function from gsap.ticker entirely when paused, since there’s nothing new to draw. Of course, the UI itself must communicate the state change—turning the record button into a pause button and revealing a stop button once capture begins. You can drive that from recorder.state rather than tracking separate booleans.
Filling the Canvas Before You Start
Jumping from an empty canvas to bars streaming across is jarring. A nicer touch is to pre-populate the timeline with zero-volume bars so there’s a baseline visual on load. A padTimeline function handles this:
// Move BAR_DURATION out of scope so it’s a shared variable.
const BAR_DURATION =
CANVAS.width / ((CONFIG.barWidth + CONFIG.barGap) * CONFIG.fps)
const padTimeline = () => {
// Doesn’t matter if we have more bars than width. We will shift them over to the correct spot
const padCount = Math.floor(CANVAS.width / CONFIG.barWidth)
for (let p = 0; p < padCount; p++) {
const BAR = {
x: CANVAS.width + CONFIG.barWidth / 2,
// Note the volume is 0
size: gsap.utils.mapRange(
0,
100,
CANVAS.height * CONFIG.barMinHeight,
CANVAS.height * CONFIG.barMaxHeight
)(volume),
}
// Add to bars Array
BARS.push(BAR)
// Add the bar animation to the timeline
// The actual pixels per second is (1 / fps * shift) * fps
// if we have 50fps, the bar needs to have moved bar width before the next comes in
// 1/50 = 4 === 50 * 4 = 200
timeline.to(
BAR,
{
x: `-=${CANVAS.width + CONFIG.barWidth}`,
ease: 'none',
duration: BAR_DURATION,
},
BARS.length * (1 / CONFIG.fps)
)
}
// Sets the timeline to the correct spot for being added to
timeline.totalTime(timeline.totalDuration() - BAR_DURATION)
}
The approach: add bars, then move the timeline playhead to the point where the bars fill the canvas. Since only padding bars exist at this moment, totalDuration gives the correct offset.
timeline.totalTime(timeline.totalDuration() - BAR_DURATION)
This looks a lot like the logic inside REPORT, so it’s worth refactoring into a single addBar helper that accepts a volume:
const addBar = (volume = 0) => {
const BAR = {
x: CANVAS.width + CONFIG.barWidth / 2,
size: gsap.utils.mapRange(
0,
100,
CANVAS.height * CONFIG.barMinHeight,
CANVAS.height * CONFIG.barMaxHeight
)(volume),
}
BARS.push(BAR)
timeline.to(
BAR,
{
x: `-=${CANVAS.width + CONFIG.barWidth}`,
ease: 'none',
duration: BAR_DURATION,
},
BARS.length * (1 / CONFIG.fps)
)
}
Both padTimeline and REPORT can then call it:
const padTimeline = () => {
const padCount = Math.floor(CANVAS.width / CONFIG.barWidth)
for (let p = 0; p < padCount; p++) {
addBar()
}
timeline.totalTime(timeline.totalDuration() - BAR_DURATION)
}
REPORT = () => {
if (recorder && recorder.state === 'recording') {
ANALYSER.getByteFrequencyData(DATA_ARR)
const VOLUME = Math.floor((Math.max(...DATA_ARR) / 255) * 100)
addBar(VOLUME)
}
if (recorder || visualizing) {
drawBars()
}
}
On initial render, invoke padTimeline and then drawBars:
padTimeline()
drawBars()
That single refactor keeps the codebase clean and gives a polished start state:
See the Pen [16. Padding out the Timeline](https://codepen.io/smashingmag/pen/OJOebYE) by Jhey.
Ending a Recording Gracefully
When you finish a recording, you choose how the visualization concludes. Options range from halting the animation in place to rolling it back to the beginning—a common pattern in UI/UX. GSAP makes the rewind straightforward: instead of clearing the timeline on stop, tweens totalTime back to the playhead position that padTimeline originally set. That means the start offset needs to be stored in a shared variable.
let START_POINT
Inside padTimeline, assign that offset:
const padTimeline = () => {
const padCount = Math.floor(CANVAS.width / CONFIG.barWidth)
for (let p = 0; p < padCount; p++) {
addBar()
}
START_POINT = timeline.totalDuration() - BAR_DURATION
// Sets the timeline to the correct spot for being added to
timeline.totalTime(START_POINT)
}
Then reset the timeline when a new recording starts:
// Reset the timeline
timeline.clear()
The result is a visualizer that retracts cleanly after playback:
See the Pen [17. Rewinding on Stop](https://codepen.io/smashingmag/pen/LYOKbKW) by Jhey.
STOP.addEventListener('click', () => {
if (recorder) recorder.stop()
AUDIO_CONTEXT.close()
// Pause the timeline
timeline.pause()
// Animate the playhead back to the START_POINT
gsap.to(timeline, {
totalTime: START_POINT,
onComplete: () => {
gsap.ticker.remove(REPORT)
}
})
})
Syncing Visualization with Playback Scrubbing
On playback, the visualization should follow the <audio> element’s position. GSAP’s API makes this surprisingly direct. The stop-and-rewind logic can be reused for scrubbing: listen to the audio element’s events and update the timeline playhead accordingly. Add or remove REPORT from the ticker on play and stop events.
There’s an edge case to note: if the audio has ended, seeking won’t trigger timeline updates if REPORT was removed during stop. You can leave REPORT attached until a new recording begins or the app state changes, trading a little performance for correctness. The payoff is that scrubbing through a recording scrubs the visualization in real time.
Visualizing Audio From Files
So far, we’ve only looked at visualizing audio coming from an input device. But what about an audio file, like an mp3? That’s a different flow, and it brings up a few interesting considerations that are worth walking through.
When working with an audio element that has a src set to a file, we can hook our visualization directly into that element. The key difference is in how we connect the AudioContext. Instead of using createMediaStreamSource(stream), we use createMediaElementSource(AUDIO).
With this setup, we only need to create the AudioContext once. Since we aren't switching between multiple audio tracks after the initial load, we can safely return early if AUDIO_CONTEXT already exists. Another thing to note: when we connect an audio element to an AudioContext, we need to insert a gain node into the chain. Without it, we won’t be able to actually hear the audio.
Handling events on the audio element changes things a bit compared to the recorder flow. For a file-based source, once the track finishes, we don’t need to keep processing the audio data. At that point, we can remove REPORT from the ticker and add drawBars instead. This way, if the user hits play again or seeks to a different position, we don’t have to re-analyze the audio. We also introduce a played variable to track whether we’ve already processed the entire track.
You might wonder why we don’t simply add and remove drawBars from the ticker based on the audio element’s play and pause state. We could do that, and we’d need to check gsap.ticker._listeners to avoid duplicate entries while seeking. But in practice, the performance gain is negligible. Once the initial processing is done, swapping out the ticker function once is clean and sufficient.
Our switch statement remains largely the same, with one important change: we only call ANALYSE if we haven’t yet played the track through completely.
Challenge: How would you extend this demo to support multiple tracks? Think about letting users pick from a dropdown or enter a URL. What would you need to reset between track changes?
Handling Disruptions
While working on “Record a Call” for Kent C. Dodds, an interesting issue surfaced that you might not expect: seeking forward in an audio track breaks the visualization. If you skip ahead in the track before it finishes playing, you’re also skipping the processing of the parts you jump over. The visualization can’t accurately represent audio data it never analyzed.
There are a few ways to approach this. One option is to build the entire animation timeline before playback begins. But to do that, you still need to process the audio once. Another approach would be to disable seeking until the track has played through completely. At this point, you’re starting to design a custom audio player, which is well beyond the scope of this article. In a real application, you might also consider server-side processing to generate the audio data ahead of time.
For “Record a Call”, we took a different route. Since we were processing audio in real time during recording, each bar’s value was already stored as a number in an Array. That data could be bundled with a recording when it was submitted, and retrieved later to rebuild the visualization instantly—no re-processing required. When loading a playback, we loop over that stored Array and reuse the addBar function to construct the timeline.
This approach gives us a significant performance win. We can build visualizations without touching the audio again.
Storing And Playing Recordings
Let’s extend our recording demo. We can store each recording in localStorage, along with its metadata. When we want to play one back, we don’t re-process the audio. Instead, we build a new bars animation from the stored data and set the audio element’s src.
Because we’ve already refactored most of the functionality into small utility functions, adding storage and playback doesn’t require much new code.
On page load, we hydrate our recordings variable from localStorage. If there’s nothing stored, we fall back to an empty Array as a default value.
It’s worth noting this isn’t meant to be a polished app—it’s about giving you the tools to build your own. Some of the user experience decisions here are made for simplicity, not perfection.
To save a recording, we hook into the ondataavailable method we’ve been using. The tricky part is that we can’t store a blob directly in localStorage. So we use the FileReader API to convert the AudioBlob into a data URL string. Once it’s a string, we can create a new recording object and persist it.
The format we use to store recordings is flexible. In this case, we use the timestamp as an id, a metadata field containing the Array for building the animation, and a timestamp field that acts like a name. For simplicity, we use window.prompt to gather that name from the user during the save step. In a production app, you might structure this differently or add more fields.
Because we aren’t using a framework, we need a way to update the UI manually. A renderRecordings function takes care of that. It’s called on page load and every time we save or delete a recording. If there are recordings, we loop over them and create list items with two buttons each: one to play and one to delete.
When no recordings exist, we show a message instead.
Playing a recording is straightforward: we set the AUDIO element’s src, generate the visualization, and play. Before we switch to a new clip—or delete one—we call a reset function to clear the UI state.
The actual play-and-visualize sequence comes down to four steps:
- Loop over the metadata
Arrayto build thetimeline. - Set the
REPORTfunction todrawBars. - Set the
AUDIOelement’ssrc. - Play the audio, which triggers the animation timeline.
Challenge: Can you think of any user experience edge cases here? For example, what if the user starts recording and then decides to play a previously saved recording? Would you disable certain controls while in record mode?
Deleting a recording is simple. We use the same reset function but set a new list of recordings in localStorage. After that, we call renderRecordings to refresh the display.
Possible Next Steps
What we have now is a functional voice recording app backed by localStorage. It’s a solid starting point for experimentation. For “Record a Call”, we even supported different canvas colors based on the team a user belonged to. This raises an interesting possibility: you could store visual themes—colors, animation speeds, etc.—against each recording and update the canvas properties accordingly when the timeline is built.
The current demo also supports downloading tracks in the .ogg format. But there are plenty of other directions you could take this. Here are some ideas to think about:
- Reskin the app with an entirely different look and feel.
- Add support for variable playback speeds.
- Create new visualization styles. For instance, how would you record and store metadata for a waveform rather than bars?
- Display the total number of recordings to the user.
- Tighten up the UX by handling edge cases like playing a recording while a new one is being recorded.
- Let users choose a specific audio input device.
- Push the visualization into 3D with something like ThreeJS.
- Enforce a recording time limit. In a real app, this would be critical for keeping data payloads manageable and ensuring recordings stay concise.
.oggis a fine one to start download. But keep in mind that browsers can’t encode to mp3 locally. In a production environment, you could offload that conversion to a serverless function using the ffmpeg package and then return the mp3 file to your user.
Moving the Demo Into React
Once the visualization grows to include state — recordings, playback, and multiple components — a framework like React starts to earn its keep. The logic from the vanilla JavaScript version carries over almost unchanged, but the way you structure and share it changes. A common approach is to wrap the visualization, audio playback, and recording controls in a parent component, with each piece as its own child.
React.useRef becomes central to this setup. It gives you a stable handle to DOM nodes and instances that need to be shared across components. In the React version of the app, refs are used to keep track of things like the GreenSock timeline and passed down through props so child components can access them.
See the Pen [23. Taking it to React Land 🚀](https://codepen.io/smashingmag/pen/ZEadLyW) by Jhey.
That pattern works, but it's not the only option. Instead of threading refs through props, you could pass event handlers down and let each component access the timeline from its own scope. Both approaches have trade-offs — refs keep a single source of truth, while event handlers can make components more self-contained.
return (
<>
<AudioVisualization
start={start}
recording={recording}
recorder={recorder}
timeline={timeline}
drawRef={draw}
metadata={metadata}
src={src}
/>
<RecorderControls
onRecord={onRecord}
recording={recording}
paused={paused}
onStop={onStop}
/>
<RecorderPlayback
src={src}
timeline={timeline}
start={start}
draw={draw}
audioRef={audioRef}
scrub={scrub}
/>
<Recordings
recordings={recordings}
onDownload={onDownload}
onDelete={onDelete}
onPlay={onPlay}
/>
</>
)
const timeline = React.useRef(gsap.timeline())
React also lets you make the visualization reactive rather than imperative. Rather than having the parent manually pad and redraw the timeline, the canvas component can listen for changes to the audio src and rebuild itself. Using React.useEffect, the timeline is reconstructed whenever the metadata changes, removing the need for manual sync between components.
React.useEffect(() => {
barsRef.current.length = 0
padTimeline()
drawRef.current = DRAW
DRAW()
if (src === null) {
metadata.current.length = 0
} else if (src && metadata.current.length) {
metadata.current.forEach(bar => addBar(bar))
gsap.ticker.add(drawRef.current)
}
}, [src])
Persisting recordings to localStorage also gets cleaner in React with a custom hook. It behaves like React.useState, but handles the storage layer for you, so components don't need to worry about when or how data is saved.
const usePersistentState = (key, initialValue) => {
const [state, setState] = React.useState(
window.localStorage.getItem(key)
? JSON.parse(window.localStorage.getItem(key))
: initialValue
)
React.useEffect(() => {
// Stringify so we can read it back
window.localStorage.setItem(key, JSON.stringify(state))
}, [key, state])
return [state, setState]
}
// Deleting a recording
setRecordings({
recordings: [
...recordings.filter(recording => recording.id !== idToDelete),
],
})
// Saving a recording
const audioSafe = e.target.result
const timestamp = new Date()
const name = prompt('Recording name?')
setRecordings({
recordings: [
...recordings,
{
audioBlob: audioSafe,
metadata: metadata.current,
name: name || timestamp.toUTCString(),
id: timestamp.getTime(),
},
],
})
The React codebase is worth exploring if you want to see how these pieces fit together. One easy extension: make the visualizer accept color values via props for the fill style, letting each recording or track render with its own palette.
Wrapping Up and Going Further
What began as a case study became a full walkthrough of audio visualization with JavaScript. You now have the pieces to build visualizations on your own — from the Web Audio API through to GSAP timelines and React integration.
For a different visual take, here's a waveform rendered with @react-three/fiber, combining React, ThreeJS, and GreenSock in one scene:
See the Pen [24. Going to 3D React Land 🚀](https://codepen.io/smashingmag/pen/oNoredR) by Jhey.
There's plenty of room to experiment from here. The demo app is a starting point — try adding new effects, different data mappings, or entirely new rendering approaches.
A CodePen Collection has all the demos from the article series, plus a few bonus examples.



