Skip to content

Waveform — the cross-framework audio waveform player

wavesurfer.js is the de-facto vanilla-JS audio-waveform engine (2D canvas + Web Audio). But its framework wrappers are lopsided: React has the official @wavesurfer/react; Angular, Svelte, Solid and Lit have thin, stale, or absent wrappers. That gap (React served, the rest stranded) is exactly what Rozie's write-once-ship-six thesis exists to close.

One Waveform.rozie source compiles to six idiomatic packages — so Angular, Svelte, Solid and Lit consumers get a category-leading waveform player for free, with the same props, events, two-way playback position, two-way interactive regions, and imperative handle as the React one.

The @rozie-ui/wavesurfer packages

PackageFrameworkShips
@rozie-ui/wavesurfer-reactReact 18+compiled .tsx + types
@rozie-ui/wavesurfer-vueVue 3.4+.vue SFC source + compiled drop-in
@rozie-ui/wavesurfer-svelteSvelte 5+.svelte source
@rozie-ui/wavesurfer-angularAngular 19+standalone component
@rozie-ui/wavesurfer-solidSolid 1.8+compiled .tsx + types
@rozie-ui/wavesurfer-litLit 3+compiled custom element + types

All six wrap wavesurfer.js v7 (wavesurfer.js@^7), declared as a peer dependency. wavesurfer renders a canvas — no external CSS import is required (unlike engines whose UI is styled DOM).

Scope — Core + Timeline + Hover + Regions

This family ships the core waveform + full playback, the two stateless plugins (the Timeline ruler and Hover cursor), and the interactive Regions plugin — draggable, resizable selections with a two-way regions binding. Register a plugin by opting in (timeline / hover) or by passing a regions array.

Quick start

The playback position is two-way bound through a single currentTime model prop (seconds). Playback writes the live position back on every timeupdate (round-trip-guarded so a programmatic seek doesn't ping-pong); a consumer write seeks the engine. The audio comes through src; playback lifecycle fires as native framework events.

React

tsx
import { useState } from 'react';
import { Waveform } from '@rozie-ui/wavesurfer-react';

export function Demo() {
  const [time, setTime] = useState(0);
  return (
    <Waveform
      src="/audio.mp3"
      currentTime={time}
      onCurrentTimeChange={setTime}
      timeline
      hover
      onReady={(d) => console.log('duration', d)}
    />
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import Waveform from '@rozie-ui/wavesurfer-vue';

const time = ref(0);
</script>

<template>
  <Waveform
    src="/audio.mp3"
    v-model:currentTime="time"
    :timeline="true"
    :hover="true"
    @ready="(d) => console.log('duration', d)"
  />
</template>

Svelte

svelte
<script lang="ts">
  import Waveform from '@rozie-ui/wavesurfer-svelte';

  let time = $state(0);
</script>

<Waveform
  src="/audio.mp3"
  bind:currentTime={time}
  timeline
  hover
  onready={(d) => console.log('duration', d)}
/>

Angular

ts
import { Component } from '@angular/core';
import { Waveform } from '@rozie-ui/wavesurfer-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [Waveform],
  template: `
    <Waveform
      src="/audio.mp3"
      [(currentTime)]="time"
      [timeline]="true"
      [hover]="true"
      (ready)="onReady($event)"
    />
  `,
})
export class DemoComponent {
  time = 0;
  onReady(d: any) { console.log('duration', d); }
}

Solid

tsx
import { createSignal } from 'solid-js';
import { Waveform } from '@rozie-ui/wavesurfer-solid';

export function Demo() {
  const [time, setTime] = createSignal(0);
  return (
    <Waveform
      src="/audio.mp3"
      currentTime={time()}
      onCurrentTimeChange={setTime}
      timeline
      hover
      onReady={(d) => console.log('duration', d)}
    />
  );
}

Lit

ts
import '@rozie-ui/wavesurfer-lit';

// <rozie-waveform> is a custom element. Bind `src`/`currentTime` as properties
// and listen for `currentTime-change` (the two-way channel) + `ready`.
const el = document.querySelector('rozie-waveform');
el.src = '/audio.mp3';
el.timeline = true;
el.addEventListener('currentTime-change', (e) => { el.currentTime = e.detail; });
el.addEventListener('ready', (e) => console.log('duration', e.detail));

Regions

Regions are draggable, resizable selections over the waveform. Pass a regions array (even empty) to register the plugin; bind it two-way to keep your state in sync as the user creates, drags, resizes, and removes them. Turn on dragToCreateRegions to let users draw new regions on empty space.

vue
<script setup lang="ts">
import { ref } from 'vue';
import Waveform from '@rozie-ui/wavesurfer-vue';

const regions = ref([
  { id: 'intro', start: 0, end: 2, color: 'rgba(138,43,226,0.2)' },
  { id: 'chorus', start: 4, end: 6, color: 'rgba(90,24,154,0.25)' },
]);
</script>

<template>
  <Waveform
    src="/audio.mp3"
    v-model:regions="regions"
    :drag-to-create-regions="true"
    region-color="rgba(138,43,226,0.2)"
    @region-created="(r) => console.log('created', r.id)"
    @region-updated="(r) => console.log('moved', r.id, r.start, r.end)"
    @region-removed="(r) => console.log('removed', r.id)"
  />
</template>

Or manage regions imperatively through the handle — addRegion(...), clearRegions(), getRegions() — and listen for the regionCreated / regionUpdated / regionClicked / regionRemoved events.

Following playback through regions

regionIn / regionOut fire as playback crosses a region's boundaries — the events behind active-segment highlighting, transcript/karaoke sync, and loop-a-region. Together with getWaveSurfer() (the engine escape hatch) they make a region loop trivial:

vue
<script setup lang="ts">
import { ref } from 'vue';
import Waveform from '@rozie-ui/wavesurfer-vue';

const wave = ref();
const regions = ref([{ id: 'loop', start: 2, end: 4, color: 'rgba(45,212,191,0.25)' }]);
const activeId = ref<string | null>(null);
const loop = ref(true);

// Track the active segment on enter; seek back to its start on exit → seamless loop.
const onIn = (r: { id: string }) => (activeId.value = r.id);
const onOut = (r: { id: string; start: number }) => {
  activeId.value = null;
  if (loop.value && r.id === 'loop') wave.value?.setTime(r.start);
};
</script>

<template>
  <Waveform
    ref="wave"
    src="/audio.mp3"
    v-model:regions="regions"
    @region-in="onIn"
    @region-out="onOut"
  />
  <p>Now playing: {{ activeId ?? '—' }}</p>
</template>

Reference

Props

There are two two-way model props — currentTime and regions (bind either with r-model / v-model / bind: / [(…)] / onCurrentTimeChange / onRegionsChange). The appearance and playback props reconcile into the live engine on change (src via load, colors/bars via setOptions, volume / playbackRate / minPxPerSec via their setters). Plugin presence is also live — timeline and hover register/unregister on the running engine, and the Regions plugin registers as soon as regions becomes an array (at construction or lazily after mount) — see Plugin presence is live. A handful are still set at construction (or at plugin-creation time)autoplay, hideScrollbar, disableInteraction, disableDragToSeek, and hoverColor / dragToCreateRegions / regionColor (read only when their plugin is (re-)created); anything not surfaced here can be passed through the options bag (peaks, duration, sampleRate, mediaControls, …).

NameTypeDefaultTwo-way (model)Runtime-updatable?Description
srcStringnullThe audio URL the waveform loads. Changing it calls load(url).
peaksunknownundefinedPre-computed waveform peaks (array of channel sample arrays, or a single number[]) — render without downloading/decoding audio; pair with duration. Construction-only.
durationNumbernullAudio duration in seconds — required alongside peaks when there's no decodable src. Construction-only.
heightNumber128The waveform height in pixels. Reconciled via setOptions.
waveColorString"#8a2be2"Color of the unplayed portion of the waveform. Reconciled via setOptions.
progressColorString"#5a189a"Color of the played (progress) portion. Reconciled via setOptions.
cursorColorString"#333333"Color of the playback cursor. Reconciled via setOptions.
cursorWidthNumber1Width of the playback cursor in pixels. Reconciled via setOptions.
barWidthunknownnullDraw the waveform as bars of this pixel width. null = continuous. Reconciled via setOptions.
barGapunknownnullPixel gap between bars (when barWidth is set). Reconciled via setOptions.
barRadiusunknownnullCorner radius of bars (when barWidth is set). Reconciled via setOptions.
minPxPerSecNumber1Minimum pixels-per-second zoom level. Reconciled via zoom.
volumeNumber1Playback volume (01). Reconciled via setVolume.
playbackRateNumber1Playback speed multiplier. Reconciled via setPlaybackRate.
autoplayBooleanfalseBegin playback as soon as the audio is ready. Construction-only.
normalizeAmplitudeBooleanfalseNormalize the waveform by its largest peak (wavesurfer's normalize option). Reconciled via setOptions. Named normalizeAmplitude to avoid the inherited Node.normalize() DOM-method collision on the Lit custom element.
hideScrollbarBooleanfalseHide the horizontal scrollbar when zoomed wider than the container. Construction-only.
disableInteractionBooleanfalseDisable click/seek interaction (the engine defaults to interactive). Construction-only.
disableDragToSeekBooleanfalseDisable drag-to-seek across the waveform. Construction-only.
timelineBooleanfalseRender a time-ruler beneath the waveform (Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
hoverBooleanfalseShow a hover cursor + time label (Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
hoverColorStringnullLine color of the Hover cursor (only when hover is on). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
regionsunknownundefinedInteractive regions — an array of { id?, start, end?, content?, color?, drag?, resize? }. Providing an array (even []) registers the Regions plugin — at construction if it's already an array, or lazily the first time regions transitions from null/undefined to an array. Two-way: user create/drag/resize/remove writes the array back (round-trip-guarded); a consumer write reconciles the live regions by id.
dragToCreateRegionsBooleanfalseAllow drawing new regions by dragging empty waveform space. Requires regions to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
regionColorStringnullDefault fill color for drag-created regions (only when dragToCreateRegions is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
optionsObject{}Raw wavesurfer WaveSurferOptions passthrough — spread into WaveSurfer.create() before the curated keys (explicit props win). Use for peaks, duration, sampleRate, mediaControls, splitChannels, …
currentTimeunknownundefinedThe current playback position in seconds. Two-way: playback writes it back on every timeupdate (round-trip-guarded); a consumer write seeks via setTime.

Events

EventPayloadFires when
readydurationThe audio is decoded and the waveform is ready.
playingPlayback starts (the engine play event).
pausedPlayback pauses (the engine pause event).
finishedPlayback reaches the end (the engine finish event).
timeupdatecurrentTimeThe playback position advances. Also drives the two-way currentTime model.
seekingcurrentTimeThe user seeks the waveform.
interactionnewTimeThe user clicks/interacts with the waveform.
loadingpercentAudio download progresses (0100).
errorerrorThe audio fails to load or decode.
regionCreatedregionA region is created (by drag or addRegion).
regionUpdatedregionA region finishes being dragged or resized.
regionClickedregionA region is clicked.
regionRemovedregionA region is removed.
regionInregionPlayback enters a region (for active-segment highlighting / transcript sync).
regionOutregionPlayback leaves a region.

Region-event payloads are the serialized descriptor { id, start, end, color, content, drag, resize }. Programmatic region changes made through a controlled regions update do not re-emit these — only genuine user gestures do.

The engine's own play / pause / finish events are surfaced as playing / paused / finished so they don't collide with the play() / pause() imperative verbs (Rozie forbids an $expose verb sharing a name with an emit).

Imperative handle

Grab a handle via your framework's native ref mechanism (useRef / template ref / bind:this / @ViewChild / ref callback / the custom element itself) and call:

MethodDescription
play()Start playback.
pause()Pause playback.
playPause()Toggle play/pause.
stop()Stop and return the cursor to the start.
seekTo(progress)Seek to a relative position (01).
setTime(seconds)Seek to an absolute position in seconds.
setVolume(v)Set volume (01).
setPlaybackRate(rate)Set the playback speed multiplier.
setZoom(pxPerSec)Set the zoom level in pixels-per-second.
load(url)Load a new audio source URL.
isPlaying()Whether audio is currently playing.
getDuration()Total duration in seconds.
getCurrentTime()Current playback position in seconds.
getWaveSurfer()The underlying wavesurfer instance (the engine escape hatch).
addRegion(opts)Add a region — { start, end?, id?, content?, color?, drag?, resize? }. Returns the created region.
clearRegions()Remove all regions.
getRegions()The live engine region objects.

Gotchas

normalizeAmplitude, not normalize

wavesurfer's option is normalize, but a reactive property named normalize would shadow the inherited Node.prototype.normalize() DOM method on the Lit custom element (a hard type error). The prop is therefore named normalizeAmplitude across all six frameworks and mapped to the engine's normalize option internally.

Plugin presence is live

timeline / hover / the Regions plugin all toggle presence live, via wavesurfer.js's registerPlugin / unregisterPlugin on the running engine — no remount. Flip timeline/hover on or off any time; the corresponding plugin instance is created/registered or unregistered/torn down in place. The Regions plugin registers as soon as regions becomes an array — at construction if it's already an array, or lazily (still no remount) the first time it transitions from null/undefined to an array later. There is no live unregister path for regions — once the Regions plugin is registered, setting regions back to null does not tear it down. hoverColor / dragToCreateRegions / regionColor are read only when their plugin is (re-)created, not live on an already-registered instance.

Give controlled regions stable ids

When you drive regions as a controlled list, include a stable id on each descriptor so reconciliation can match update-vs-add. Regions you add without an id get one assigned by the engine, and — because the binding is two-way — that id is written back into your bound array. If you hold an id-less array and never consume the writeback, a changing array reference can re-add the same region.

Offline / deterministic rendering

To render a waveform without decoding audio (CI, tests, SSR-adjacent), pass a pre-computed peaks array and a duration — no src needed:

ts
<Waveform :peaks="[/* -1..1 samples */]" :duration="8" />

Pre-v1.0 — internal monorepo.