Waveform

Draws audio: a seekable waveform with regions and markers, a live waveform that scrolls with the signal, and a bar visualizer.

Audio UI

Components for drawing audio. Waveform shows a whole file and lets you scrub through it. LiveWaveform follows a signal as it plays. BarVisualizer turns that signal into a few bars you style in CSS. All three wrap the Waveform primitive from @audio-ui/react, which works with any audio: decoded PCM samples, a microphone or a stream from your server.

Overview

  • Two kinds of data: a file's shape is known in advance, so Waveform takes values (amplitudes from 0 to 1, at any resolution). A live signal is not, so LiveWaveform and BarVisualizer take a source and follow it outside React.
  • Value: value is the playback position, from 0 to duration (default 1, a fraction; pass the track length in seconds to work in seconds). The primitive owns the seek position, not the audio: connect onValueChange to your own <audio> element.
  • Asymmetric shape: give values the lowest and highest sample of each slice ({ min, max }) and the waveform is drawn asymmetric, as in a DAW. Plain amplitudes draw the same shape above and below the middle.
  • Part of the audio: range shows only a stretch of the audio, such as a clip's window onto its source or a zoomed view. The playhead, seeking, hover, regions and markers follow that stretch. See Showing part of the audio.
  • Resolution: the canvas draws as many bars as fit its width and keeps the loudest sample of each bar's slice, so detail holds at any size. Bars align to physical pixels, so their edges stay sharp at any pixel density or zoom. See Feeding real audio.
  • Shapes: shape="mirror" (the default) centers the bars, "bars" stands them on the baseline, and "line" joins the levels into one shape. A live waveform can also be a "scope": raw samples traced as one line, like an oscilloscope.
  • On the timeline: WaveformRegion is a stretch you can move and resize, such as a loop. WaveformMarker is a labelled point. WaveformHover follows the pointer and shows the time under it.
  • Performance: moving parts are painted outside React, and only when something changes. A waveform that is off screen or at rest uses no frames. When reduced motion is requested, live waveforms repaint a few times per second, without smoothing or idle animation.
  • Accessibility: the seekable waveform holds a native <input type="range">: it takes focus, carries aria-label, announces the played share (or the text from getAriaValueText, such as a time), and supports the arrow keys, Home, End, Page Up and Page Down. Each region handle holds one too. Live waveforms are images: give them an aria-label.

Installation

pnpm dlx shadcn@latest add @audio-ui/waveform

It installs the element alone. For the player above, ready to run: npx shadcn@latest add @audio-ui/waveform-demo.

Anatomy

Import the primitive and assemble its parts.

import { Waveform } from "@audio-ui/react";

<Waveform.Root>
  <Waveform.Control>
    <Waveform.Canvas />
    <Waveform.Playhead />
    <Waveform.Region>
      <Waveform.RegionHandle edge="start" />
      <Waveform.RegionHandle edge="end" />
    </Waveform.Region>
    <Waveform.Marker />
    <Waveform.Hover />
    <Waveform.Thumb />
  </Waveform.Control>
</Waveform.Root>

Usage

import {
  BarVisualizer,
  LiveWaveform,
  Waveform,
  WaveformHover,
  WaveformMarker,
  WaveformRegion,
  WaveformRegionHandle,
} from "@/components/audio-ui/waveform";

A file, in seconds, with a playhead line and handle:

<Waveform
  aria-label="Track progress"
  duration={audio.duration}
  onValueChange={(seconds) => {
    audio.currentTime = seconds;
  }}
  value={currentTime}
  values={values}
  variant="playhead"
/>

A microphone, scrolling as it comes in:

const { level } = useAudioSignal(analyser);

<LiveWaveform aria-label="Microphone input" mode="scrolling" source={level} />;

A voice, as bars:

const { bands } = useAudioSignal(analyser);

<BarVisualizer aria-label="Agent speaking" mirrored source={bands} />;

Feeding real audio

values is a plain prop: the component does not decode audio. @audio-ui/core/analysis and the audio-decode lib (npx shadcn@latest add @audio-ui/audio-decode) turn a real audio file into values: decodeAudioChannels (from audio-decode) reads every channel's PCM samples out of an AudioContext, computePeaks reduces them to each slice's lowest and highest sample across all channels (so a take panned hard to one side still draws), and normalizePeaks scales them so the loudest sample reaches full height.

import { computePeaks, normalizePeaks } from "@audio-ui/core/analysis";
import { decodeAudioChannels } from "@/lib/audio-decode";

const context = new AudioContext();
const channels = await decodeAudioChannels(context, await file.arrayBuffer());

const values = normalizePeaks(computePeaks(channels, 4096));

The second argument to computePeaks is a resolution, not a bar count: the canvas fits whatever you pass to its own width and to any range. Compute it once per file, with more slices than the widest view you expect will show (a few thousand covers a zoomed-in clip editor). For a simpler shape, downsampleWaveform and normalizeWaveform give one amplitude per slice instead.

Showing part of the audio

range is the stretch of the audio to show, [start, end] in duration units. Everything follows it: the bars are drawn from that stretch of values alone, a press seeks inside it, the input's keys stay inside it, and regions and markers are placed across it.

<Waveform
  aria-label="Clip"
  duration={source.duration}
  range={[clip.offset, clip.offset + clip.length]}
  values={source.peaks}
/>

When the position is outside the stretch, the root carries data-outside and the playhead is hidden, rather than pinned to an edge it hasn't reached. Past the largest canvas a browser will draw (16,384 pixels a side), the bitmap is drawn coarser rather than going blank.

Following playback

value re-renders React each time it changes, which is fine for a timeupdate event a few times a second. For a playhead that glides every animation frame, pass a source, an object in the shape of an external store:

interface WaveformSource {
  getSnapshot: () => number; // the position, in `duration` units
  subscribe: (listener: () => void) => () => void;
}

The waveform subscribes to it and moves the playhead, repaints the canvas and updates the input itself. While the pointer scrubs, the waveform follows the pointer and ignores the source until release; the same holds for a controlled value that updates late.

Regions and markers

Anything inside Waveform is laid over the canvas, on the same timeline. A region is [start, end] in duration units; drag its body to move it, and give it a handle per edge to resize it:

const [loop, setLoop] = useState<readonly [number, number]>([48, 96]);

<Waveform aria-label="Track progress" duration={180} values={values}>
  <WaveformRegion onValueChange={setLoop} value={loop}>
    <WaveformRegionHandle aria-label="Loop start" edge="start" />
    <WaveformRegionHandle aria-label="Loop end" edge="end" />
  </WaveformRegion>
  <WaveformMarker value={124}>Chorus</WaveformMarker>
  <WaveformHover format={formatTime} />
</Waveform>

A press on a region never seeks the waveform underneath. Each handle is a slider of its own: it takes focus, and the arrow keys, Page Up / Page Down, Home and End move its edge, which stops minLength short of the other one. Leave the handles out for a region that only moves, or pass movable={false} for one that only resizes.

Live signals

A live waveform reads from a source, an object in the shape of an external store whose snapshot is the current levels, 0–1 each:

interface LevelsSource {
  getSnapshot: () => ArrayLike<number>;
  subscribe: (listener: () => void) => () => void;
}

The use-audio-signal hook (npx shadcn@latest add @audio-ui/use-audio-signal) reads two of them from an AnalyserNode, every animation frame and only while something is drawing them:

const { level, bands, samples } = useAudioSignal(analyser, { bandCount: 32 });
  • level is the loudest sample of the moment, as a single level. Draw it with mode="scrolling": the waveform records it over time, the newest entering from the right, or from the middle outwards with mirrored. When the signal stops (pass null as the analyser to pause), the trace holds still.
  • bands is the spectrum as log-spaced frequency bands, low to high. Draw it with a BarVisualizer, or a LiveWaveform in its default mode="static": the bars stay in place and follow the bands, rising at once and falling smoothly.
  • samples is the raw waveform of the moment, -1 to 1. Draw it with shape="scope".

Any signal works. createSource from @audio-ui/react makes a source you push to yourself, from a WebSocket, a worklet or a test:

import { createSource } from "@audio-ui/react";

const levels = createSource(new Float32Array(16));

socket.onmessage = (event) => levels.set(event.data);

While its signal is audible, a live waveform carries data-active. idle picks what the bars do meanwhile ("pulse" breathes, "wave" ripples), and loading sweeps across them while a connection is on its way.

Theming

Colors come from CSS, and the canvas repaints when the theme changes:

CSS variables · 5

--waveform-position
Set on the root: the playback position, as a percentage.
--waveform-level
Set on each bar of a BarVisualizer: its level, 0–1.

The bars of a BarVisualizer are plain elements: restyle them with .cn-bar-visualizer-bar. Those the playback position has passed carry data-played.

API reference

Root

Groups the parts of a waveform and owns what they show: the audio's shape (peaks, known upfront, or a live levels signal) and, for a seekable one, the playback position in duration units. The position comes from value, or from a live source that the waveform follows straight in the DOM without re-rendering React; it is exposed as the --waveform-position CSS variable (a percentage). While a live signal is audible the element carries data-active. Renders a <div> element.

Props · 13

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Region

A stretch of the timeline, such as a loop or a selection, laid over the waveform from value[0] to value[1]. Dragging its body moves it; add a Waveform.RegionHandle per edge to resize it. Renders a <div> element.

Props · 7

Data attributes · 2

data-disabled
Present when disabled.
data-dragging
Whether a pointer is moving or resizing the region.

RegionHandle

Resizes one edge of the region: by dragging, or from the keyboard through the native <input type="range"> it holds, which carries the accessible name (aria-label / aria-labelledby). Renders a <div> element.

Props · 2

Data attributes · 2

data-disabled
Present when disabled.
data-dragging
Whether a pointer is moving or resizing the region.

Control

The seekable surface: a press jumps to the pointer and a drag scrubs. It tracks the hovered position for Waveform.Hover, sets touch-action: none so touch scrubbing doesn't scroll the page, and holds the native <input type="range"> that takes focus, handles the keyboard and carries the accessible name (aria-label / aria-labelledby). Leave it out for a waveform that is only shown. Renders a <div> element.

Props · 3

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Bars

Draws the waveform as a fixed number of elements, one Waveform.Bar each, for a visualizer styled in plain CSS. Each bar's level, 0…1, is written to its --waveform-level CSS variable outside React: levels rise at once and fall smoothly, and nothing is written while they rest or sit off screen. Bars the playback position has passed carry data-played. Its children are a function called once per bar; left out, plain bars are rendered. Renders a <div> element.

Props · 6

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Bar

One bar of Waveform.Bars. Its level, 0…1, is its --waveform-level CSS variable: size it with height: calc(var(--waveform-level) * 100%), or the width for a vertical layout. Renders a <span> element.

Props · 1

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Canvas

Draws the waveform on a canvas, laid out on the device pixel grid: as many bars as fit its width, every edge on a physical pixel, so it stays sharp at any pixel density and zoom level. It paints only when something changed, and not at all while it is off screen. With peaks, the played part is drawn in one color and the rest in another. With a live levels signal, mode picks between the current levels across the width and a scrolling record of the loudest one. The scope variant takes raw samples instead of levels, and traces them as they are. Colors come from CSS: --waveform-color and --waveform-progress-color (both default to the text color). The bar corner radius is barRadius, or the --waveform-bar-radius CSS length. Renders a <canvas> element.

Props · 11

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Thumb

A handle centered on the playback position. Renders a <div> element.

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Marker

A point of interest on the timeline, such as a cue or a chapter, positioned at value. Its children label it. Renders a <div> element.

Props · 1

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Playhead

A marker at the playback position, with no interaction of its own. Renders a <div> element.

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Hover

Shown while the pointer is over the Control, positioned at the hovered point. Its children receive the hovered position, to print a time. Renders a <div> element.

Props · 1

Data attributes · 3

data-disabled
Present when disabled.
data-dragging
Whether a pointer is scrubbing the waveform.
data-loading
Whether the audio is still on its way: there is nothing to seek yet.

Registry component

aria-label, aria-labelledby, and aria-describedby are forwarded to the element that needs them: the waveform's input, a region handle's input, or the live waveform itself.

Waveform

Props · 18

WaveformRegion

Props · 7

WaveformRegionHandle takes edge ("start" or "end") and getAriaValueText. WaveformMarker takes value and shows its children as a label. WaveformHover takes format, which prints the hovered position; without it only the line shows.

LiveWaveform

Props · 8

BarVisualizer

Props · 9

@audio-ui/react/waveform exposes the parts these components are built from:

Parts · 8

Waveform.Root
Owns the data (peaks, or a live levels source) and the position (value, source, duration). Carries data-active while a live signal is audible.
Waveform.Control
The seekable surface; holds the input. Leave it out for a waveform that is only shown.
Waveform.Canvas
Draws on a canvas: variant, mode, scrollSpeed, barWidth, barGap, barRadius, minBarHeight, lineWidth, gain, mirrored, idle.
Waveform.Bars, Waveform.Bar
Draws as elements: count, minLevel, gain, mirrored, idle. Each bar gets --waveform-level, data-index and data-played.
Waveform.Region, Waveform.RegionHandle
A stretch of the timeline, and the handle of one of its edges.
Waveform.Marker
A point on the timeline.
Waveform.Playhead, Waveform.Thumb
Follow the playback position.
Waveform.Hover
Shown at the hovered position; its children receive that position.

Every part accepts a render prop and className / style as functions of state, and exposes that state as data attributes such as data-disabled, data-dragging and data-loading.

Examples

Default

Audio UI

Region, markers and hover time

Audio UI

Live

The signal is recorded as it comes in, the newest level entering from the right.

Audio UI

Player, wired to a real uploaded file

Audio UI

More Shadcn Waveform components

Browse 4 production-ready audio/ui Waveform components for players, mixers, and synth interfaces. These examples use the headless primitives from @audio-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 4 Shadcn Waveform components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the audio/ui library.

Built by Ouest Labs, the source code is available on GitHub.
RSS