Waveform
ProA recording drawn as mirrored bars, with a playhead you can drag and an optional color per bar for each speaker.
npx prodular-ui add waveformPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Player
Press play, or click, drag or use the arrow keys on the bars to move through the recording.
Voicemail from Daniel Reyes
Tidewater Freight, accounts payable. Today at 9:12
"use client"
import * as React from "react"import { PauseIcon, PlayIcon } from "lucide-react"
import { Button } from "@/components/ui/button"import { Waveform } from "@/components/ui/waveform"
const DURATION = 38// A fixed shape so the server and the browser draw the same bars.const VALUES = Array.from({ length: 96 }, (_, i) => { const pause = i % 23 > 19 ? 0.05 : 1 return pause * (0.3 + 0.6 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.41)))})
function format(seconds: number) { const s = Math.floor(seconds) return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
export default function WaveformVoicemail() { const [time, setTime] = React.useState(11.4) const [playing, setPlaying] = React.useState(false)
React.useEffect(() => { if (!playing) return let last = performance.now() let frame = requestAnimationFrame(function tick(now) { setTime((t) => Math.min(t + (now - last) / 1000, DURATION)) last = now frame = requestAnimationFrame(tick) }) return () => cancelAnimationFrame(frame) }, [playing])
if (playing && time >= DURATION) setPlaying(false)
return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl border bg-card p-4 shadow-xs"> <div className="flex flex-col gap-0.5"> <p className="m-0 text-sm font-medium">Voicemail from Daniel Reyes</p> <p className="m-0 text-[13px] text-muted-foreground">Tidewater Freight, accounts payable. Today at 9:12</p> </div> <div className="flex items-center gap-3"> <Button size="icon" variant="outline" className="rounded-full" aria-label={playing ? "Pause" : "Play"} onClick={() => { if (time >= DURATION) setTime(0) setPlaying((p) => !p) }} > {playing ? <PauseIcon className="fill-current" /> : <PlayIcon className="translate-x-px fill-current" />} </Button> <Waveform values={VALUES} progress={time / DURATION} duration={DURATION} onSeek={(p) => setTime(p * DURATION)} label="Voicemail from Daniel Reyes" className="h-10" /> <span className="w-10 shrink-0 text-right text-[13px] text-muted-foreground tabular-nums">{format(time)}</span> </div> </div> )}Colored by speaker
Each bar in the color of whoever was talking. Without progress there is no playhead and every bar is drawn in full.
- Maya Okafor
- Daniel Reyes
import { Waveform } from "@/components/ui/waveform"
const speakers = [ { name: "Maya Okafor", color: "var(--chart-1)" }, { name: "Daniel Reyes", color: "var(--chart-2)" },]// Who talks when, in bars out of 140, and a fixed shape for the loudness.const turns: [number, number, 0 | 1][] = [ [0, 26, 0], [28, 55, 1], [57, 86, 0], [88, 114, 1], [116, 136, 0],]const bars = Array.from({ length: 140 }, (_, i) => { const turn = turns.find(([from, to]) => i >= from && i < to) return { value: turn ? 0.3 + 0.6 * Math.abs(Math.sin(i * 1.3) * Math.cos(i * 0.29)) : 0.05, color: turn ? speakers[turn[2]].color : null, }})
export default function WaveformSpeakers() { return ( <div className="flex w-full max-w-xl flex-col gap-3"> <Waveform values={bars.map((b) => b.value)} colors={bars.map((b) => b.color)} label="Call with Tidewater Freight, colored by speaker" className="h-14" /> <ul className="m-0 flex list-none gap-4 p-0 text-[13px] text-muted-foreground"> {speakers.map((s) => ( <li key={s.name} className="flex items-center gap-1.5"> <span aria-hidden="true" className="size-2 rounded-full" style={{ backgroundColor: s.color }} /> {s.name} </li> ))} </ul> </div> )}Anatomy
<Waveform values={levels} progress={currentTime / duration} duration={duration} onSeek={(p) => (audio.currentTime = p * duration)} label="Voicemail from Daniel Reyes"/>Guidance
Use it when
- Use it for recordings people review: calls, voicemails, meetings, voice notes.
- Color bars by speaker when the question is who said what, and keep the same color in the Transcript and the legend.
- Pair it with a play button and the elapsed time. The waveform shows where you are, not whether it is playing.
Avoid
- Live microphone levels. Use VoiceLevels from Voice input for that.
- Drawing fake bars for a recording you have not analysed. Show a skeleton until the levels are ready.
- More colors than speakers, or colors that mean something other than who is talking.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Pass values computed from the audio: one peak or RMS value per bar, scaled to 0 to 1. Downsample long recordings to 80 to 200 bars. On narrow screens the waveform merges neighbouring bars itself, keeping the loudest, so pass the full set.
- Pass progress as currentTime / duration on every timeupdate, and duration in seconds so the slider announces the time.
- Pass onSeek to make it seekable, and set audio.currentTime = progress * duration inside it. Without onSeek it is an image, so give it a label that says what was recorded.
- For speaker colors use chart tokens such as var(--chart-1), never raw hex, and reuse each speaker's color in TranscriptTurn color.
API
Waveform
| Prop | Type | Default | Description |
|---|---|---|---|
| values | number[] | One value from 0 to 1 per bar. | |
| progress | number | How much has played, 0 to 1. Leave it out for no playhead. | |
| colors | (string | null)[] | A CSS color per bar, for example var(--chart-1). Missing entries use the foreground. | |
| duration | number | Seconds. Sets the spoken value and a 5 second arrow key step. | |
| onSeek | (progress: number) => void | Makes it a slider: click, drag, arrows, Page Up and Down, Home and End. | |
| label | string | "Recording" | Accessible name. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add waveform# or, with the registry in components.jsonnpx shadcn@latest add @prodular/waveform