Framework integration
wavesurfer.js is a browser-only library — it renders to a <canvas> element and relies on browser APIs (HTMLElement, ResizeObserver, Web Audio). Every framework integration follows the same fundamental pattern: create the instance once the DOM is ready, and destroy it when the component is torn down.
The universal pattern
Regardless of framework, the lifecycle always looks like this:
- On mount — obtain a reference to a real DOM element, then call
WaveSurfer.create({ container: el }). - On unmount — call
ws.destroy()to stop playback, remove DOM nodes, cancel any in-flight fetches, and unregister all event listeners.
import WaveSurfer from 'wavesurfer.js'
// Pseudo-code — replace onMount/onUnmount with your framework's hooks
onMount(() => {
const ws = WaveSurfer.create({
container: containerElement, // a real HTMLElement
url: '/audio.mp3',
})
ws.on('ready', () => console.log('Ready to play'))
onUnmount(() => ws.destroy())
})
Plugins are passed via the plugins option array and are initialised automatically. You can also register them later with ws.registerPlugin(plugin).
See Core concepts for the full options reference.
React
Option A — @wavesurfer/react package
The official @wavesurfer/react package provides a useWavesurfer hook and a WavesurferPlayer component. Install it with:
npm install @wavesurfer/react
Because @wavesurfer/react is versioned independently of wavesurfer.js, its exact API is documented in the @wavesurfer/react package readme on npm. Refer there for the current hook signature and component props.
Option B — manual useRef + useEffect
If you prefer to manage the instance yourself — or need to support a custom setup — the manual approach is straightforward:
import { useRef, useEffect, useMemo } from 'react'
import WaveSurfer from 'wavesurfer.js'
function Waveform({ url, waveColor = '#4a9eff', height = 80 }) {
const containerRef = useRef(null)
// Memoize the options object so it is stable across renders.
// If waveColor or height change, a new object is created and the
// effect re-runs — but only then.
const options = useMemo(
() => ({ waveColor, height }),
[waveColor, height],
)
useEffect(() => {
if (!containerRef.current) return
const ws = WaveSurfer.create({
container: containerRef.current,
url,
...options,
})
ws.on('ready', (duration) => {
console.log('Ready, duration:', duration)
})
// Returning the cleanup function is what prevents the double-init
// problem in React StrictMode (see callout below).
return () => ws.destroy()
}, [url, options])
return <div ref={containerRef} />
}
React StrictMode and double-render pitfalls
React 18 StrictMode intentionally mounts every component twice in development to surface side effects. Without a proper cleanup return value, useEffect will create two WaveSurfer instances that both attach to the same container — and any plugin passed in an inline array literal (plugins: [MyPlugin.create()]) will be registered twice.
Always return () => ws.destroy() from the effect. This is not optional in StrictMode.
Also, never construct plugins or peaks arrays inline inside useEffect or as JSX props. Every render creates a new array reference, so React treats it as a changed dependency and re-runs the effect, either causing infinite re-init or doubling plugin registrations. Memoize them:
// BAD — new array on every render, causes infinite loop or double-init
useEffect(() => {
WaveSurfer.create({ container: ref.current, plugins: [TimelinePlugin.create()] })
}, [])
// GOOD — stable reference, effect runs once
const plugins = useMemo(() => [TimelinePlugin.create()], [])
useEffect(() => {
const ws = WaveSurfer.create({ container: ref.current, plugins })
return () => ws.destroy()
}, [plugins])
Similarly, if you pass pre-computed peaks from a parent component, wrap them in useMemo or store them in a useRef so they are the same reference between renders.
ready not firing with pre-computed peaks?
When you supply both peaks and duration (but no url), wavesurfer renders the waveform immediately — without making a network request. The ready event still fires, but it does so inside a resolved Promise.resolve() micro-task, which means it fires after the current synchronous code block. Attach your ws.on('ready', ...) listener before any await in your setup code, or just let the effect run synchronously as shown above.
Angular
Create the instance in ngAfterViewInit (when the template’s DOM is guaranteed to exist) and tear it down in ngOnDestroy:
import {
Component,
ElementRef,
ViewChild,
AfterViewInit,
OnDestroy,
} from '@angular/core'
import WaveSurfer from 'wavesurfer.js'
@Component({
selector: 'app-waveform',
template: `<div #waveformEl></div>`,
})
export class WaveformComponent implements AfterViewInit, OnDestroy {
@ViewChild('waveformEl') waveformEl!: ElementRef<HTMLDivElement>
private ws!: WaveSurfer
ngAfterViewInit(): void {
this.ws = WaveSurfer.create({
container: this.waveformEl.nativeElement,
url: '/audio.mp3',
waveColor: '#4a9eff',
})
this.ws.on('ready', (duration) => {
console.log('Ready, duration:', duration)
})
}
ngOnDestroy(): void {
this.ws?.destroy()
}
}
Do not call WaveSurfer.create() in the constructor or ngOnInit — the @ViewChild reference is not yet resolved at those stages and container will be null.
Vue
Use onMounted / onBeforeUnmount with a template ref:
<template>
<div ref="waveformEl" />
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import WaveSurfer from 'wavesurfer.js'
const waveformEl = ref(null)
let ws = null
onMounted(() => {
ws = WaveSurfer.create({
container: waveformEl.value,
url: '/audio.mp3',
waveColor: '#4a9eff',
})
ws.on('ready', (duration) => {
console.log('Ready, duration:', duration)
})
})
onBeforeUnmount(() => {
ws?.destroy()
})
</script>
If you need to re-initialise when a prop changes (e.g. a new audio URL), use a watch that calls ws.destroy() before creating the new instance, or call ws.load(newUrl) to swap the audio without recreating the full instance.
Next.js / SSR
wavesurfer.js cannot run on the server — it accesses HTMLElement, ResizeObserver, Web Audio, and other browser globals that do not exist in a Node.js environment. Any attempt to import and run it during server-side rendering will throw.
Dynamic import with ssr: false
The cleanest solution in Next.js (App Router or Pages Router) is to dynamically import the component that uses wavesurfer so it is never included in the server bundle:
// app/page.jsx (or pages/index.jsx)
import dynamic from 'next/dynamic'
const Waveform = dynamic(() => import('../components/Waveform'), { ssr: false })
export default function Page() {
return <Waveform url="/audio.mp3" />
}
The Waveform component itself can then use the plain useRef + useEffect pattern shown in the React section above — no special Next.js handling needed inside it.
typeof window guard
If you cannot use dynamic, guard the import with a typeof window check so the module is never evaluated on the server:
let WaveSurfer
if (typeof window !== 'undefined') {
WaveSurfer = (await import('wavesurfer.js')).default
}
ResizeObserver is not defined
This error means wavesurfer (or its renderer) was imported and executed in a non-browser environment. ResizeObserver does not exist in Node.js. The fix is always the same: ensure the import and the WaveSurfer.create() call run only in the browser, via ssr: false or a typeof window !== 'undefined' guard.
For further guidance see Core concepts and Performance.