Wave Video

A waveform player synced to a video element, built on wavesurfer.js.

import { WaveVideo } from "@/components/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

Install the required shadcn/ui primitives.

npx shadcn@latest add button skeleton

Add the component via jsrepo.

npx jsrepo add @waves-cn/ui/wave-video

This will automatically install wave-cn, wavesurfer.js and lucide-react.

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add button skeleton

Copy wave-cn.tsx into your project.

/** * A React component for wavesurfer.js * * Usage: * * import WavesurferPlayer from '@wavesurfer/react' * * <WavesurferPlayer *   url="/my-server/audio.ogg" *   waveColor="purple" *   height={100} *   onReady={(wavesurfer) => console.log('Ready!', wavesurfer)} * /> */import {  useState,  useMemo,  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">;/** * Props for the Wavesurfer component * @public */export type WavesurferProps = PartialWavesurferOptions &  OnWavesurferEvents & {    className?: string;  };/** * Shared waveform defaults applied to every useWavesurfer instance. * Override any of these by passing the corresponding option explicitly. */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>;/** * Use wavesurfer instance */function useWavesurferInstance(  containerRef: RefObject<HTMLDivElement | null>,  options: Partial<WaveSurferOptions>,): WaveSurfer | null {  const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);  // Flatten options object to an array of keys and values to compare them deeply in the hook deps  // Exclude plugins from deep comparison since they are mutated during initialization  const optionsWithoutPlugins = useMemo(() => {    // eslint-disable-next-line @typescript-eslint/no-unused-vars    const { plugins, ...rest } = options;    return rest;  }, [options]);  const flatOptions = useMemo(    () => Object.entries(optionsWithoutPlugins).flat(),    [optionsWithoutPlugins],  );  // Create a wavesurfer instance  useEffect(() => {    if (!containerRef?.current) return;    const ws = WaveSurfer.create({      ...optionsWithoutPlugins,      plugins: options.plugins,      container: containerRef.current,    });    setWavesurfer(ws);    return () => {      ws.destroy();    };    // Only recreate if plugins array reference changes (not on mutation)    // Users should memoize the plugins array to prevent unnecessary re-creation  }, [containerRef, options.plugins, ...flatOptions]);  return wavesurfer;}/** * Use wavesurfer state */function useWavesurferState(wavesurfer: WaveSurfer | null): {  isReady: boolean;  isPlaying: boolean;  currentTime: number;} {  const [isReady, setIsReady] = useState<boolean>(false);  const [isPlaying, setIsPlaying] = useState<boolean>(false);  const [hasFinished, setHasFinished] = useState<boolean>(false);  const [currentTime, setCurrentTime] = useState<number>(0);  useEffect(() => {    if (!wavesurfer) return;    const unsubscribeFns = [      wavesurfer.on("load", () => {        setIsReady(false);        setIsPlaying(false);        setCurrentTime(0);      }),      wavesurfer.on("ready", () => {        setIsReady(true);        setIsPlaying(false);        setHasFinished(false);        setCurrentTime(0);      }),      wavesurfer.on("finish", () => {        setHasFinished(true);      }),      wavesurfer.on("play", () => {        setIsPlaying(true);      }),      wavesurfer.on("pause", () => {        setIsPlaying(false);      }),      wavesurfer.on("timeupdate", () => {        setCurrentTime(wavesurfer.getCurrentTime());      }),      wavesurfer.on("destroy", () => {        setIsReady(false);        setIsPlaying(false);      }),    ];    return () => {      unsubscribeFns.forEach((fn) => fn());    };  }, [wavesurfer]);  return useMemo(    () => ({      isReady,      isPlaying,      hasFinished,      currentTime,    }),    [isPlaying, hasFinished, currentTime, isReady],  );}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;/** * Parse props into wavesurfer options and events */function useWavesurferProps(  props: WavesurferProps,): [PartialWavesurferOptions, OnWavesurferEvents] {  // Props starting with `on` are wavesurfer events, e.g. `onReady`  // The rest of the props are wavesurfer options  return useMemo<[PartialWavesurferOptions, OnWavesurferEvents]>(() => {    const allOptions = { ...props };    const allEvents = { ...props };    for (const key in allOptions) {      if (isEventProp(key)) {        delete allOptions[key as keyof WavesurferProps];      } else {        delete allEvents[key as keyof WavesurferProps];      }    }    return [allOptions, allEvents];  }, [props]);}/** * Subscribe to wavesurfer events */function useWavesurferEvents(  wavesurfer: WaveSurfer | null,  events: OnWavesurferEvents,) {  const flatEvents = useMemo(() => Object.entries(events).flat(), [events]);  // Subscribe to events  useEffect(() => {    if (!wavesurfer) return;    const eventEntries = Object.entries(events);    if (!eventEntries.length) return;    const unsubscribeFns = eventEntries.map(([name, handler]) => {      const event = getEventName(name);      return wavesurfer.on(event, (...args) =>        (handler as WavesurferEventHandler<WaveSurferEvents[typeof event]>)(          wavesurfer,          ...args,        ),      );    });    return () => {      unsubscribeFns.forEach((fn) => fn());    };  }, [wavesurfer, ...flatEvents]);}/** * Wavesurfer player component * @see https://wavesurfer.xyz/docs/modules/wavesurfer * @public */const WavesurferPlayer = memo((props: WavesurferProps): ReactElement => {  const containerRef = useRef<HTMLDivElement | null>(null);  const { className, ...propsWithoutClassName } = props;  const [rawOptions, events] = useWavesurferProps(propsWithoutClassName);  // Apply WAVESURFER_DEFAULTS and resolve CSS var() tokens — same logic as  // useWavesurfer — so WavesurferPlayer benefits from shared defaults too.  const waveColor =    (rawOptions.waveColor as string | undefined) ??    WAVESURFER_DEFAULTS.waveColor;  const progressColor =    (rawOptions.progressColor as string | undefined) ??    WAVESURFER_DEFAULTS.progressColor;  const resolvedWaveColor = useCssVar(waveColor);  const resolvedProgressColor = useCssVar(progressColor);  const options = useMemo(() => {    const { waveColor: _wc, progressColor: _pc, ...rest } = rawOptions;    const cleanOptions = Object.fromEntries(      Object.entries(rest).filter(([, v]) => v !== undefined),    ) as PartialWavesurferOptions;    return {      ...WAVESURFER_DEFAULTS,      ...cleanOptions,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    } as PartialWavesurferOptions;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(rawOptions)]);  const wavesurfer = useWavesurferInstance(containerRef, options);  useWavesurferEvents(wavesurfer, events);  // Create a container div  return <div ref={containerRef} className={className} />;});/** * @public */export default WavesurferPlayer;/** * React hook for wavesurfer.js * * Automatically applies shared defaults (colors, height, bar shape, volume). * Any option passed explicitly will override the defaults. * CSS var() tokens in waveColor and progressColor are resolved at runtime * so they work correctly with the Canvas API. * * ``` * import { useWavesurfer } from '@/components/cors/wavesurfer-player' * * const App = () => { *   const containerRef = useRef<HTMLDivElement | null>(null) * *   const { wavesurfer, isReady, isPlaying, currentTime } = useWavesurfer({ *     container: containerRef, *     url: '/my-server/audio.ogg', *   }) * *   return <div ref={containerRef} /> * } * ``` * * @public */export function useWavesurfer({  container,  waveColor = WAVESURFER_DEFAULTS.waveColor,  progressColor = WAVESURFER_DEFAULTS.progressColor,  ...options}: Omit<WaveSurferOptions, "container"> & {  container: RefObject<HTMLDivElement | null>;}): ReturnType<typeof useWavesurferState> & {  wavesurfer: ReturnType<typeof useWavesurferInstance>;} {  // Resolve CSS var() tokens so the Canvas API receives actual color values  const resolvedWaveColor = useCssVar(waveColor as string);  const resolvedProgressColor = useCssVar(progressColor as string);  // Memoize mergedOptions so useWavesurferInstance gets a stable object reference.  // Without this, a new object is created every render → flatOptions changes →  // the instance is destroyed and recreated on every render, wiping spread props.  // Also strip undefined values so they don't silently override WAVESURFER_DEFAULTS.  const mergedOptions = useMemo(() => {    const cleanOptions = Object.fromEntries(      Object.entries(options).filter(([, v]) => v !== undefined),    ) as Partial<WaveSurferOptions>;    return {      ...WAVESURFER_DEFAULTS,      ...cleanOptions,      waveColor: resolvedWaveColor,      progressColor: resolvedProgressColor,    } as Partial<WaveSurferOptions>;    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [resolvedWaveColor, resolvedProgressColor, ...Object.values(options)]);  const wavesurfer = useWavesurferInstance(container, mergedOptions);  const state = useWavesurferState(wavesurfer);  return useMemo(() => ({ ...state, wavesurfer }), [state, wavesurfer]);}export function useCssVar(value: string): string {  const [resolved, setResolved] = useState(value);  useEffect(() => {    // Only resolve if the value looks like a CSS variable    const match = value.match(/^var\((--[^)]+)\)$/);    if (!match) {      setResolved(value);      return;    }    const varName = match[1];    const raw = getComputedStyle(document.documentElement)      .getPropertyValue(varName)      .trim();    // shadcn stores values as bare HSL channels e.g. "222.2 47.4% 11.2%"    // Canvas needs a full color string — wrap in hsl() if needed    const isHslChannels = /^[\d.]+ [\d.]+% [\d.]+%$/.test(raw);    setResolved(raw ? (isHslChannels ? `hsl(${raw})` : raw) : value);  }, [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 the media prop, 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 stylingwaveColor, progressColor, barWidth, barGap, barRadius accept any CSS value including var(--*) tokens.
  • Granular class control — separate className props for the root, video element, waveform container, and canvas wrapper.
  • Video element passthroughvideoProps forwards 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 audio/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 when media is set. This is the same pattern used by WaveRecorder.

Examples

Custom Waveform

waveColor and progressColor accept any CSS value including var(--*) tokens.

import { WaveVideo } from "@/components/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

PropTypeDefaultDescription
urlstringVideo file URL.
waveColorstringvar(--muted-foreground)Wave bar color. Accepts CSS vars, hex, hsl, oklch.
progressColorstringvar(--primary)Progress bar color. Same formats as waveColor.
waveHeightnumber64Wave canvas height in px.
barWidthnumber3Bar width in px.
barGapnumber2Gap between bars in px.
barRadiusnumber2Bar border radius in px.
showVideobooleantrueShow the native <video> element.
classNamestringRoot wrapper class.
styleCSSPropertiesRoot wrapper inline style.
videoClassNamestringClass applied to the <video> element.
videoStyleCSSPropertiesInline style applied to the <video> element.
waveformClassNamestringClass applied to the waveform + controls container.
waveClassNamestringClass applied to the WavesurferPlayer canvas wrapper.
videoPropsVideoHTMLAttributesExtra props forwarded to <video> (poster, loop, muted, etc.). src, ref, controls, playsInline are managed internally.

On this page