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-video
npx shadcn@latest add @waves-cn/wave-video
yarn dlx shadcn@latest add @waves-cn/wave-video
bunx --bun shadcn@latest add @waves-cn/wave-video

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.

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 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 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 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/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

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