Draws audio: a seekable waveform with regions and markers, a live waveform that scrolls with the signal, and a bar visualizer.
Base UI
import { Waveform } from "@/components/audio/elements/waveform";
// Stand-in for the peaks of a decoded audio file.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.
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 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.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.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.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.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.<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.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>import {
BarVisualizer,
LiveWaveform,
Waveform,
WaveformHover,
WaveformMarker,
WaveformRegion,
WaveformRegionHandle,
} from "@/components/audio/elements/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} />;values is a plain prop: the component does not decode audio. The waveform-dsp lib (npx shadcn@latest add @audio/waveform-dsp) turns a real audio file into values: decodeAudioChannels 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,
decodeAudioChannels,
normalizePeaks,
} from "@/lib/waveform-dsp";
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.
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.
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.
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.
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/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.
Colors come from CSS, and the canvas repaints when the theme changes:
CSS variables · 5
--waveform-position--waveform-levelBarVisualizer: 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.
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
value is in (typically seconds).duration.peaks: a microphone, an analyser, a stream.{ min, max }, -1…1), which draws the waveform lopsided where the audio is. The parts fit it to their size.[start, end] in duration units: a clip's window onto its source, a zoomed view. The playhead, seeking, hover, regions and markers all follow it. Defaults to the whole audio. While the position is outside it, the root carries data-outside.value.duration.duration.Data attributes · 3
data-disableddata-draggingdata-loadingA 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
duration units. Defaults to the root's step.[start, end] in duration units.Data attributes · 2
data-disableddata-draggingResizes 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
"0:12". Defaults to its share of the timeline, e.g. "30%".Data attributes · 2
data-disableddata-draggingThe 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
"0:12 of 1:42". Defaults to the played share, e.g. "30%".[start, end] in duration units: feed it to a Waveform.Region. A plain press and drag still seek. eventDetails.cancel() keeps the selection as it was.Data attributes · 3
data-disableddata-draggingdata-loadingDraws 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
Waveform.Bar.Data attributes · 3
data-disableddata-draggingdata-loadingOne 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-disableddata-draggingdata-loadingDraws 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
--waveform-bar-radius CSS length.scope trace, in CSS pixels.levels signal is drawn: static spreads the current levels across the width; scrolling records the loudest one over time, the newest entering from the right.mirror centers the bars on the middle line, bars stands them on the baseline, and line joins the levels into one continuous, centered shape. scope traces raw samples, -1…1, as a single line.Data attributes · 3
data-disableddata-draggingdata-loadingA handle centered on the playback position. Renders a <div> element.
Data attributes · 3
data-disableddata-draggingdata-loadingA 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
duration.Data attributes · 3
data-disableddata-draggingdata-loadingA marker at the playback position, with no interaction of its own. Renders a <div> element.
Data attributes · 3
data-disableddata-draggingdata-loadingShown 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-disableddata-draggingdata-loadingaria-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.
Props · 18
0–1 amplitudes, or each slice's lowest and highest sample, -1–1.duration units.0 to duration.value.value is in.PageUp / PageDown and Shift + arrows.eventDetails.reason is "press", "drag", "keyboard", or "input-change"; call eventDetails.cancel() to reject it."0:12 of 1:42".playhead adds a playhead line and a handle.Props · 7
[start, end] in duration units.eventDetails.edge is "start", "end" or "both"; eventDetails.reason is "drag", "keyboard" or "input-change".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.
Props · 8
static spreads the current levels across the width; scrolling records the loudest one over time.scrolling moves, in pixels per second.scope traces raw samples instead of levels.static mode, what the bars do while there is no signal.Props · 9
vertical stacks the bars and grows them sideways.@audio-ui/react/waveform exposes the parts these components are built from:
Parts · 8
Waveform.Rootpeaks, or a live levels source) and the position (value, source, duration). Carries data-active while a live signal is audible.Waveform.ControlWaveform.Canvasvariant, mode, scrollSpeed, barWidth, barGap, barRadius, minBarHeight, lineWidth, gain, mirrored, idle.Waveform.Bars, Waveform.Barcount, minLevel, gain, mirrored, idle. Each bar gets --waveform-level, data-index and data-played.Waveform.Region, Waveform.RegionHandleWaveform.MarkerWaveform.Playhead, Waveform.ThumbWaveform.HoverEvery 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.
Base UI
import { Waveform } from "@/components/audio/elements/waveform";
// Stand-in for the peaks of a decoded audio file.Base UI
"use client";
import { useState } from "react";The signal is recorded as it comes in, the newest level entering from the right.
Base UI
"use client";
import { createSource } from "@audio-ui/react";Base UI
"use client";
import { createSource } from "@audio-ui/react";