Wave Video
A waveform player synced to a video element, built on wavesurfer.js.
import { WaveVideo } from "@/components/waves-cn/wave-video";export default function WaveVideoDemo() { return ( <div className="w-full max-w-2xl mx-auto p-6"> <WaveVideo url="/coastline.mp4" videoProps={{ loop: true }} /> </div> );}Installation
pnpm dlx shadcn@latest add @waves-cn/wave-videonpx shadcn@latest add @waves-cn/wave-videoyarn dlx shadcn@latest add @waves-cn/wave-videobunx --bun shadcn@latest add @waves-cn/wave-videoInstall the peer dependency.
npm install wavesurfer.jsInstall the required shadcn primitives.
npx shadcn@latest add button skeletonCopy wave-cn.tsx into your project.
import { useState, useEffect, useRef, memo, type ReactElement, type RefObject,} from "react";import WaveSurfer, { type WaveSurferEvents, type WaveSurferOptions,} from "wavesurfer.js";type WavesurferEventHandler<T extends unknown[]> = ( wavesurfer: WaveSurfer, ...args: T) => void;type OnWavesurferEvents = { [K in keyof WaveSurferEvents as `on${Capitalize<K>}`]?: WavesurferEventHandler< WaveSurferEvents[K] >;};type PartialWavesurferOptions = Omit<WaveSurferOptions, "container">;export type WavesurferProps = PartialWavesurferOptions & OnWavesurferEvents & { className?: string; };export const WAVESURFER_DEFAULTS = { waveColor: "var(--muted-foreground)", progressColor: "var(--primary)", height: 64, barWidth: 3, barGap: 2, barRadius: 2, minPxPerSec: 1, cursorWidth: 0,} as const satisfies Partial<WaveSurferOptions>;const EVENT_PROP_RE = /^on([A-Z])/;const isEventProp = (key: string) => EVENT_PROP_RE.test(key);const getEventName = (key: string) => key.replace(EVENT_PROP_RE, (_, $1) => $1.toLowerCase(), ) as keyof WaveSurferEvents;// ─── Component ───────────────────────────────────────────────────────────────const WavesurferPlayer = memo( (props: WavesurferProps): ReactElement => { const containerRef = useRef<HTMLDivElement | null>(null); const wsRef = useRef<WaveSurfer | null>(null); const { className, ...rest } = props; // ── Separate options from event handlers const options: Partial<WaveSurferOptions> = {}; const eventProps: OnWavesurferEvents = {}; for (const key in rest) { if (isEventProp(key)) eventProps[key as keyof OnWavesurferEvents] = rest[ key as keyof typeof rest ] as never; else options[key as keyof PartialWavesurferOptions] = rest[ key as keyof typeof rest ] as never; } // ── Resolve CSS vars const waveColor = (options.waveColor as string | undefined) ?? WAVESURFER_DEFAULTS.waveColor; const progressColor = (options.progressColor as string | undefined) ?? WAVESURFER_DEFAULTS.progressColor; const resolvedWaveColor = useCssVar(waveColor); const resolvedProgressColor = useCssVar(progressColor); // ── Keep event handlers in a ref — changes never cause re-subscription const eventsRef = useRef(eventProps); eventsRef.current = eventProps; // ── Keep non-url options in a ref — changes applied imperatively const optionsRef = useRef(options); optionsRef.current = options; // ── Create instance only when url or structural options change const url = options.url as string | undefined; const height = (options.height as number | undefined) ?? WAVESURFER_DEFAULTS.height; const barWidth = (options.barWidth as number | undefined) ?? WAVESURFER_DEFAULTS.barWidth; const barGap = (options.barGap as number | undefined) ?? WAVESURFER_DEFAULTS.barGap; const barRadius = (options.barRadius as number | undefined) ?? WAVESURFER_DEFAULTS.barRadius; const minPxPerSec = (options.minPxPerSec as number | undefined) ?? WAVESURFER_DEFAULTS.minPxPerSec; const cursorWidth = (options.cursorWidth as number | undefined) ?? WAVESURFER_DEFAULTS.cursorWidth; const dragToSeek = options.dragToSeek as boolean | undefined; const media = options.media as HTMLMediaElement | undefined; useEffect(() => { if (!containerRef.current) return; const ws = WaveSurfer.create({ ...WAVESURFER_DEFAULTS, url, height, barWidth, barGap, barRadius, minPxPerSec, cursorWidth, dragToSeek, media, plugins: optionsRef.current.plugins, waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, container: containerRef.current, }); wsRef.current = ws; // Subscribe to all events via ref — always calls latest handler const eventEntries = Object.keys(eventsRef.current); const unsubs = eventEntries.map((name) => { const event = getEventName(name); return ws.on(event, (...args) => ( eventsRef.current[ name as keyof OnWavesurferEvents ] as WavesurferEventHandler<WaveSurferEvents[typeof event]> )?.(ws, ...args), ); }); return () => { unsubs.forEach((fn) => fn()); ws.destroy(); wsRef.current = null; }; // Only remount when these primitive options change — NOT handlers, NOT colors // eslint-disable-next-line react-hooks/exhaustive-deps }, [ url, height, barWidth, barGap, barRadius, minPxPerSec, cursorWidth, dragToSeek, ]); // ── Apply color changes imperatively — zero remount on theme switch useEffect(() => { wsRef.current?.setOptions({ waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, }); }, [resolvedWaveColor, resolvedProgressColor]); // ── Skeleton const [isReady, setIsReady] = useState(false); useEffect(() => { const ws = wsRef.current; if (!ws) return; // Sync immediately with current instance — avoids skeleton flash on re-render // when the instance already exists and audio is already decoded setIsReady(ws.getDuration() > 0); const unsubs = [ ws.on("ready", () => setIsReady(true)), ws.on("load", () => setIsReady(false)), ws.on("destroy", () => setIsReady(false)), ]; return () => unsubs.forEach((fn) => fn()); // Re-attach when instance changes (url change creates new instance) // eslint-disable-next-line react-hooks/exhaustive-deps }, [wsRef.current]); return ( <div className={className} style={{ position: "relative" }}> {!isReady && ( <div style={{ height, width: "100%", position: "absolute", inset: 0, borderRadius: 4, background: "hsl(var(--muted))", animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite", }} /> )} <div ref={containerRef} style={!isReady ? { opacity: 0 } : undefined} /> </div> ); }, (prev, next) => { // Only remount when structural audio options change — ignore handlers and className const STRUCTURAL_KEYS = [ "url", "height", "barWidth", "barGap", "barRadius", "minPxPerSec", "cursorWidth", "dragToSeek", "waveColor", "progressColor", ]; return STRUCTURAL_KEYS.every( (k) => prev[k as keyof WavesurferProps] === next[k as keyof WavesurferProps], ); },);export default WavesurferPlayer;// ─── Hook ────────────────────────────────────────────────────────────────────export function useWavesurfer({ container, waveColor = WAVESURFER_DEFAULTS.waveColor, progressColor = WAVESURFER_DEFAULTS.progressColor, ...options}: Omit<WaveSurferOptions, "container"> & { container: RefObject<HTMLDivElement | null>;}) { const resolvedWaveColor = useCssVar(waveColor as string); const resolvedProgressColor = useCssVar(progressColor as string); const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null); const [isReady, setIsReady] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const url = options.url as string | undefined; const height = (options.height as number | undefined) ?? WAVESURFER_DEFAULTS.height; const barWidth = (options.barWidth as number | undefined) ?? WAVESURFER_DEFAULTS.barWidth; const barGap = (options.barGap as number | undefined) ?? WAVESURFER_DEFAULTS.barGap; const barRadius = (options.barRadius as number | undefined) ?? WAVESURFER_DEFAULTS.barRadius; const minPxPerSec = (options.minPxPerSec as number | undefined) ?? WAVESURFER_DEFAULTS.minPxPerSec; useEffect(() => { if (!container.current) return; const ws = WaveSurfer.create({ ...WAVESURFER_DEFAULTS, ...options, waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, container: container.current, }); setWavesurfer(ws); const unsubs = [ ws.on("load", () => { setIsReady(false); setIsPlaying(false); setCurrentTime(0); }), ws.on("ready", () => { setIsReady(true); }), ws.on("play", () => { setIsPlaying(true); }), ws.on("pause", () => { setIsPlaying(false); }), ws.on("timeupdate", () => { setCurrentTime(ws.getCurrentTime()); }), ws.on("destroy", () => { setIsReady(false); setIsPlaying(false); }), ]; return () => { unsubs.forEach((fn) => fn()); ws.destroy(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, height, barWidth, barGap, barRadius, minPxPerSec]); useEffect(() => { wavesurfer?.setOptions({ waveColor: resolvedWaveColor, progressColor: resolvedProgressColor, }); }, [wavesurfer, resolvedWaveColor, resolvedProgressColor]); return { wavesurfer, isReady, isPlaying, currentTime };}// ─── CSS var resolver ────────────────────────────────────────────────────────export function useCssVar(value: string): string { const [resolved, setResolved] = useState(value); useEffect(() => { const match = value.match(/^var\((--[^)]+)\)$/); if (!match) { setResolved(value); return; } const varName = match[1]; const resolve = () => { const raw = getComputedStyle(document.documentElement) .getPropertyValue(varName) .trim(); const isHsl = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw); setResolved(raw ? (isHsl ? `hsl(${raw})` : raw) : value); }; resolve(); const observer = new MutationObserver(resolve); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"], }); return () => observer.disconnect(); }, [value]); return resolved;}Copy wave-video.tsx into your project.
import { useRef, useState, useCallback, type CSSProperties, type VideoHTMLAttributes,} from "react";import { Button } from "@/components/ui/button";import { Play, Pause } from "lucide-react";import { cn } from "@/lib/utils";import WavesurferPlayer from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";/** * Props for the WaveVideo component */export type WaveVideoProps = { /** Video file URL to load */ url: string; /** Wave bar color. Accepts any CSS value including var(--*) tokens @default "var(--muted-foreground)" */ waveColor?: string; /** Progress bar color. Accepts any CSS value including var(--*) tokens @default "var(--primary)" */ progressColor?: string; /** Wave canvas height in px @default 64 */ waveHeight?: number; /** Bar width in px @default 3 */ barWidth?: number; /** Gap between bars in px @default 2 */ barGap?: number; /** Bar border radius in px @default 2 */ barRadius?: number; /** Show the native video element @default true */ showVideo?: boolean; /** Root element class */ className?: string; /** Root element inline style */ style?: CSSProperties; /** Class applied to the <video> element */ videoClassName?: string; /** Inline style applied to the <video> element */ videoStyle?: CSSProperties; /** Class applied to the waveform + controls container */ waveformClassName?: string; /** Class applied to the WavesurferPlayer canvas wrapper */ waveClassName?: string; /** Extra props forwarded to the <video> element (e.g. poster, loop, muted) */ videoProps?: VideoHTMLAttributes<HTMLVideoElement>;};/** * Waveform synced to a video element — wavesurfer.js reads the video * as its media source via the `media` prop so playback stays in sync. */export function WaveVideo({ url, waveColor, progressColor, waveHeight, barWidth, barGap, barRadius, showVideo = true, className, style, videoClassName, videoStyle, waveformClassName, waveClassName, videoProps,}: WaveVideoProps) { const wavesurferRef = useRef<WaveSurfer | null>(null); // Callback ref — triggers a re-render the moment the <video> mounts // so WavesurferPlayer receives the actual HTMLVideoElement, not null. const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null); const videoCallbackRef = useCallback((el: HTMLVideoElement | null) => { setVideoEl(el); }, []); const [isReady, setIsReady] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const togglePlay = useCallback(() => wavesurferRef.current?.playPause(), []); return ( <div className={cn("w-full space-y-2 max-w-2xl", className)} style={style}> {/* Video element — wavesurfer uses it as media source via the `media` prop */} {showVideo && ( <video ref={videoCallbackRef} src={url} controls={false} playsInline className={cn("w-full mx-auto bg-black", videoClassName)} style={videoStyle} {...videoProps} /> )} {/* Waveform + controls — only mounts once the video element is available */} {videoEl && ( <div className={cn( "w-full flex items-center gap-2 rounded-md overflow-hidden bg-muted/40 px-2", waveformClassName, )} > <Button size="icon" onClick={togglePlay} disabled={!isReady} aria-label={isPlaying ? "Pause" : "Play"} > {isPlaying ? ( <Pause className="size-4" /> ) : ( <Play className="size-4" /> )} </Button> <WavesurferPlayer url="" media={videoEl} waveColor={waveColor} progressColor={progressColor} height={waveHeight} barWidth={barWidth} barGap={barGap} barRadius={barRadius} dragToSeek className={cn("w-full", waveClassName)} onReady={(ws) => { wavesurferRef.current = ws; setIsReady(true); }} onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)} onFinish={() => setIsPlaying(false)} onDestroy={() => { wavesurferRef.current = null; setIsReady(false); }} /> </div> )} </div> );}export default WaveVideo;Update the import paths to match your project setup.
Features
- Synced waveform — wavesurfer.js uses the
<video>element as its media source via themediaprop, so the waveform progress always stays in sync with the video. - Callback ref pattern — the waveform mounts only after the
<video>element is in the DOM, eliminating any null reference race condition. - Drag to seek — click or drag on the waveform to seek the video.
- Custom waveform styling —
waveColor,progressColor,barWidth,barGap,barRadiusaccept any CSS value includingvar(--*)tokens. - Granular class control — separate
classNameprops for the root, video element, waveform container, and canvas wrapper. - Video element passthrough —
videoPropsforwards any native<video>attribute (poster,loop,muted, etc.) without wrapping.
Usage
import { WaveVideo } from "@/registry/components/wave-video"<WaveVideo url="/video/track.mp4" />How the sync works
WaveVideo passes the mounted <video> element directly to WavesurferPlayer via the media prop. wavesurfer.js then uses it as its wave/video source instead of fetching a URL — so both the native video controls and the waveform share the exact same HTMLVideoElement:
// Simplified internals
<video ref={videoCallbackRef} src={url} />
<WavesurferPlayer
url="" // ignored when media is provided
media={videoEl} // ← the live <video> element
onReady={(ws) => { wavesurferRef.current = ws }}
/>
url=""is required by the type but ignored at runtime whenmediais set. This is the same pattern used byWaveRecorder.
Examples
Custom Waveform
waveColor and progressColor accept any CSS value including var(--*) tokens.
import { WaveVideo } from "@/components/waves-cn/wave-video";export default function WaveVideoCustomWave() { return ( <div className="w-full max-w-2xl mx-auto p-6"> <WaveVideo url="/coastline.mp4" waveColor="var(--chart-1)" progressColor="var(--chart-2)" barWidth={4} barGap={3} barRadius={8} waveHeight={80} videoProps={{ controls: true }} /> </div> );}With Video Poster & Loop
Use videoProps to pass any native <video> attribute.
<WaveVideo
url="/video/track.mp4"
videoProps={{
poster: "/video/thumbnail.jpg",
loop: true,
muted: true,
}}
/>API Reference
WaveVideo
| Prop | Type | Default | Description |
|---|---|---|---|
url | string | — | Video file URL. |
waveColor | string | var(--muted-foreground) | Wave bar color. Accepts CSS vars, hex, hsl, oklch. |
progressColor | string | var(--primary) | Progress bar color. Same formats as waveColor. |
waveHeight | number | 64 | Wave canvas height in px. |
barWidth | number | 3 | Bar width in px. |
barGap | number | 2 | Gap between bars in px. |
barRadius | number | 2 | Bar border radius in px. |
showVideo | boolean | true | Show the native <video> element. |
className | string | — | Root wrapper class. |
style | CSSProperties | — | Root wrapper inline style. |
videoClassName | string | — | Class applied to the <video> element. |
videoStyle | CSSProperties | — | Inline style applied to the <video> element. |
waveformClassName | string | — | Class applied to the waveform + controls container. |
waveClassName | string | — | Class applied to the WavesurferPlayer canvas wrapper. |
videoProps | VideoHTMLAttributes | — | Extra props forwarded to <video> (poster, loop, muted, etc.). src, ref, controls, playsInline are managed internally. |