Media
Audio Waveform
A canvas waveform with a click/drag/keyboard scrubber — feed it precomputed peaks or let it decode a src, and it reports seeks while your own audio element plays.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/audio-waveform.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AudioWaveform" component: a canvas
amplitude waveform that doubles as a seek control. It never plays audio — the
consumer owns the `<audio>` element (or any clock), pushes `currentTime` in,
and applies the times the component reports through `onSeek`. That split is
the point: one waveform can drive a chat bubble, a full player, or a
transcript editor without ever owning playback.
Contract
- forwardRef div extending HTMLAttributes<HTMLDivElement>; the ref lands on
the outer container. Merge className with cn().
- Data source is a discriminated union, so TypeScript rejects the half-filled
cases at compile time:
{ peaks: number[]; duration: number; src?: string } // precomputed
| { src: string; peaks?: undefined; duration?: number } // decode on the fly
`peaks` wins when both are present: hand the same src to your `<audio>` and
the component still skips the download + decode entirely.
- Props: currentTime (number, required, seconds), onSeek?(time: number),
regions? ({ start, end, label? }[]), height? (64), barWidth? (3),
barGap? (2), resolution? (512 buckets when decoding), keyboardStep? (5s),
disabled? (false), label? ("Audio position", the slider's accessible name),
onDecoded?({ peaks, duration }) — fired once after a successful decode so
the app can cache the peaks and never decode that clip again.
- Clamp every numeric prop (height >= 24, barWidth >= 1, barGap >= 0,
keyboardStep > 0, resolution 16..4096, non-finite falls back to the default).
barWidth = 0 or keyboardStep = 0 would otherwise produce an empty draw loop
or a seek that never moves.
Behavior
- Decode path (src, no peaks): fetch(src, { signal }) -> arrayBuffer ->
new AudioContext().decodeAudioData -> peak-per-bucket -> ready.
* One AbortController per src. Re-check `signal.aborted` after every await;
a late resolve must not setState on an unmounted component.
* The AudioContext is closed in a finally block AND in the effect cleanup —
browsers cap concurrent contexts (~6 in Chrome), so a leaked one per clip
breaks the sixth waveform on the page.
* Kick the async work off inside queueMicrotask so the effect body itself
performs no synchronous setState (react-hooks/set-state-in-effect) and
StrictMode's mount->cleanup->mount only sends one request.
* A changed src resets peaks/error during render (prevSrc adjust-state), not
in an effect — otherwise the previous clip's wave stays on screen while
the new one downloads.
* Peak extraction walks the first two channels only and strides through long
buckets (cap ~512 samples per bucket): a 3-minute stereo clip is ~16M
samples, and reading all of them blocks the main thread for tens of ms to
move a bar by less than a pixel.
- Display peaks are normalised against their own maximum, so server-side
arrays in any scale (0..1, 0..255, dB-ish) and quiet clips all render
legibly; non-finite entries count as silence.
- The peaks array is resampled to however many bars fit (max per bar), so the
same 512-bucket array works at 320px and at 1200px with no re-decode.
- Canvas painting:
* Backing store = CSS size x pixel ratio, where the ratio is
max(devicePixelContentBoxSize / cssWidth, devicePixelRatio), clamped to
1..3 and to 4096 device px. Both measurements lie in opposite directions —
device-pixel-content-box reports CSS px under an emulated device scale
factor, devicePixelRatio reports 1 inside a window whose scale factor was
overridden on a real 2x screen — and undersizing is exactly what "blurry
on Retina" is.
* ResizeObserver observes the canvas with { box: "device-pixel-content-box" }
inside a try/catch: unsupported engines throw a WebIDL TypeError rather
than ignoring the option, so the catch re-observes with default options.
* Snap every x/y/width/height to the device-pixel grid; a bar landing on a
half device pixel is drawn as two half-lit columns.
* Two passes: whole wave at ~0.34 alpha, then the same path re-filled at
alpha 1 inside a clip rect ending at the playhead. The bar straddling the
playhead is split mid-bar instead of flipping a whole bar early or late.
* Ink is `getComputedStyle(canvas).color`, i.e. currentColor, handed to
fillStyle verbatim — no color parsing, and retinting is one className. A
MutationObserver on documentElement (class/style/data-theme) bumps a tick
so a dark-mode flip repaints instead of keeping the old ink.
- Interaction lives on a real DOM element, never on the canvas: an
`absolute inset-0` div with role="slider", aria-valuemin/max/now and
aria-valuetext ("0:38 of 1:36"). The canvas is aria-hidden.
* Pointer: pointerdown seeks and captures the pointer; pointermove while
captured keeps seeking (clamped to 0..duration); pointerup/cancel release.
* Keyboard: Left/Down -keyboardStep, Right/Up +keyboardStep, PageUp/PageDown
+/-max(step, duration/10), Home 0, End duration; preventDefault only on
keys actually handled.
* Hover (not while dragging) draws a 1px cursor plus a timestamp chip that
left-aligns below 8% and right-aligns above 92% so it never hangs off the
ends.
* disabled keeps the slider in the tree (screen readers still get the
position) but sets aria-disabled, tabIndex -1 and ignores all input.
- States: loading (animated comb skeleton + "Decoding…"), error, ready. Error
text is translated for humans, not copied from the console: a fetch
TypeError (offline / DNS / CORS) becomes "Couldn't load audio", an
EncodingError becomes "Couldn't decode audio", a bad status keeps
"Request failed (404)". Times are formatted mm:ss / h:mm:ss with an
explicit "en-US" Intl.NumberFormat — never Intl.*(undefined).
Rendering & styling
- Semantic tokens only: the container carries text-primary and everything
inked follows currentColor (canvas bars, playhead `bg-current`, region bands
via color-mix(in oklab, currentColor 12%/30%, transparent)). Chrome uses
bg-popover/text-popover-foreground (hover chip), bg-background/90 +
text-foreground (region label), text-muted-foreground (times, skeleton),
text-destructive (errors), focus-visible ring tokens. No hex/rgb/oklch
literals anywhere.
- The loading skeleton is one node with a repeating-linear-gradient comb, not
one span per bar — a 1200px waveform would otherwise mount ~240 throwaway
elements per clip. It carries animate-pulse with
motion-reduce:animate-none; nothing else animates, so reduced motion costs
no functionality.
- Region bands render as DOM overlays (pointer-events-none) rather than canvas
fills, so their labels stay real, selectable, truncatable text.
Customization levers
- Tint: pass className="text-destructive" (or any text token) — bars, playhead
and region bands all follow currentColor. Change IDLE_ALPHA to widen or
narrow the played/unplayed contrast.
- Density: barWidth/barGap/height turn the same data into a chat-bubble strip
(height 32, barWidth 2, barGap 1) or a full editor lane (height 120).
resolution trades decode detail for work.
- Mirror-mode: draw each bar from the top instead of centred, or draw min/max
envelopes instead of peaks, by changing only the y/height math in tracePath.
- Drop the footer time row for embedded use, or add total-duration-only /
remaining-time variants — it reads clampedTime and displayDuration.
- Regions: swap the band overlay for coloured `var(--chart-N)` fills when the
host theme has a real palette, or make them clickable by removing
pointer-events-none and rendering a button per region above the slider.
- Normalisation: remove the max-normalisation pass if you need absolute
loudness comparable across clips.Concepts
- Peaks over pixels — the amplitude array is the contract, not the drawing. Precomputing it server-side removes the download and the decode from the client entirely;
onDecodedexists so a one-time browser decode can be cached into that same shape. - Controlled playhead — the component owns no clock.
currentTimein,onSeekout; if the consumer ignoresonSeek, the playhead honestly refuses to move rather than faking a scrub. - Canvas draws, DOM interacts — hundreds of bars would be hundreds of nodes, so painting goes to canvas (
aria-hidden) while a singlerole="slider"element owns focus, ARIA value text, pointer capture and the keyboard map. - Device-pixel snapping — the backing store is sized from the larger of
devicePixelContentBoxSizeanddevicePixelRatio(each under-reports on a different setup) and every bar edge is rounded onto the device grid; measured on a 2x screen this cut half-lit "blur" pixels along a scanline from 45.8% to 1.9%. - Clip-split progress — one path, two fills: the played portion is the same geometry re-filled inside a clip rect that ends exactly at the playhead, so the boundary lands mid-bar instead of quantising to whole bars.
- Cancellable decode — one
AbortControllerpersrc, anabortedcheck after everyawait, andAudioContext.close()in both thefinallyand the effect cleanup; browsers cap concurrent contexts, so a leak breaks later clips on the same page rather than just wasting memory.
Image Annotation
Click an image to drop numbered pins, drag them around and attach a note — coordinates stored as percentages of the picture, so they never drift when the image resizes.
Media Grid Selectable
A multi-select photo and video grid — Shift range, cmd/ctrl toggle, two-dimensional arrow keys, a selection cap, lazy thumbnails and type-icon fallbacks.