Wave Timeline

A waveform player with timeline markers built on wavesurfer.js.

Loading...
import WaveTimeline from "@/components/waves-cn/wave-timeline";export default function WaveTimelineDemo() {  return (    <div className="w-full  mx-auto p-6">      <WaveTimeline src="/coastline.mp3" title="SoundHelix — Song 1" />    </div>  );}

Installation

pnpm dlx shadcn@latest add @waves-cn/wave-timeline
npx shadcn@latest add @waves-cn/wave-timeline
yarn dlx shadcn@latest add @waves-cn/wave-timeline
bunx --bun shadcn@latest add @waves-cn/wave-timeline

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

npx shadcn@latest add button slider card 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-timeline.tsx into your project.

import * as React from "react";import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline.esm.js";import { cn } from "@/lib/utils";import { Card, CardContent } from "@/components/ui/card";import { Button } from "@/components/ui/button";import { Slider } from "@/components/ui/slider";import {  Play,  Pause,  Volume2,  VolumeX,  Loader2,  RotateCcw,  ZoomIn,  ZoomOut,} from "lucide-react";import WavesurferPlayer from "@/lib/wave-cn";import type WaveSurfer from "wavesurfer.js";// ─── Typesexport interface TimelineOptions {  height?: number;  timeInterval?: number;  primaryLabelInterval?: number;  secondaryLabelInterval?: number;  fontSize?: string;}export interface WaveTimelineProps {  src: string;  title?: string;  defaultVolume?: number;  waveColor?: string;  progressColor?: string;  barWidth?: number;  barGap?: number;  barRadius?: number;  waveHeight?: number;  defaultZoom?: number;  minZoom?: number;  maxZoom?: number;  topTimeline?: TimelineOptions | false;  bottomTimeline?: TimelineOptions | false;  onPlay?: () => void;  onPause?: () => void;  onFinish?: () => void;  onTimeUpdate?: (currentTime: number, duration: number) => void;  className?: string;}// ─── Helpersfunction formatTime(t: number): string {  const m = Math.floor(t / 60);  const s = Math.floor(t % 60);  return `${m}:${s.toString().padStart(2, "0")}`;}// ─── Componentexport function WaveTimeline({  src,  title,  defaultVolume = 0.8,  waveColor,  progressColor,  barWidth = 2,  barGap = 1,  barRadius = 2,  waveHeight = 80,  defaultZoom = 50,  minZoom = 10,  maxZoom = 500,  topTimeline = {},  bottomTimeline = false,  onPlay,  onPause,  onFinish,  onTimeUpdate,  className,}: WaveTimelineProps) {  const wavesurferRef = React.useRef<WaveSurfer | null>(null);  const [isReady, setIsReady] = React.useState(false);  const [isPlaying, setIsPlaying] = React.useState(false);  const [volume, setVolume] = React.useState(defaultVolume);  const [isMuted, setIsMuted] = React.useState(false);  const [duration, setDuration] = React.useState(0);  const [currentTime, setCurrentTime] = React.useState(0);  const [zoom, setZoom] = React.useState(defaultZoom);  // ── Memoized plugins ──────────────────────────────────────────────────────  // Stable array reference — WavesurferPlayer uses reference equality to decide  // whether to recreate the instance, so this must not change on every render.  const plugins = React.useMemo(() => {    if (typeof window === "undefined") return [];    const list: InstanceType<typeof TimelinePlugin>[] = [];    if (topTimeline !== false) {      list.push(        TimelinePlugin.create({          height: topTimeline.height ?? 20,          insertPosition: "beforebegin",          timeInterval: topTimeline.timeInterval ?? 0.5,          primaryLabelInterval: topTimeline.primaryLabelInterval ?? 5,          secondaryLabelInterval: topTimeline.secondaryLabelInterval ?? 1,          style: {            fontSize: topTimeline.fontSize ?? "11px",            color: "var(--muted-foreground)",            background: "var(--muted)",          },        }),      );    }    if (bottomTimeline !== false) {      list.push(        TimelinePlugin.create({          height: bottomTimeline.height ?? 14,          timeInterval: bottomTimeline.timeInterval ?? 0.1,          primaryLabelInterval: bottomTimeline.primaryLabelInterval ?? 1,          secondaryLabelInterval: bottomTimeline.secondaryLabelInterval,          style: {            fontSize: bottomTimeline.fontSize ?? "10px",            color: "var(--muted-foreground)",            background: "var(--muted)",          },        }),      );    }    return list;  }, [JSON.stringify(topTimeline), JSON.stringify(bottomTimeline)]);  // ── Event handlers  const handleReady = React.useCallback(    (ws: WaveSurfer) => {      wavesurferRef.current = ws;      ws.setVolume(defaultVolume);      setDuration(ws.getDuration());      setIsReady(true);    },    [defaultVolume],  );  const handlePlay = React.useCallback(() => {    setIsPlaying(true);    onPlay?.();  }, [onPlay]);  const handlePause = React.useCallback(() => {    setIsPlaying(false);    onPause?.();  }, [onPause]);  const handleFinish = React.useCallback(    (_ws: WaveSurfer) => {      setIsPlaying(false);      onFinish?.();    },    [onFinish],  );  const handleTimeupdate = React.useCallback(    (ws: WaveSurfer) => {      const t = ws.getCurrentTime();      setCurrentTime(t);      onTimeUpdate?.(t, ws.getDuration());    },    [onTimeUpdate],  );  const handleSeeking = React.useCallback((ws: WaveSurfer) => {    setCurrentTime(ws.getCurrentTime());  }, []);  const handleDestroy = React.useCallback(() => {    wavesurferRef.current = null;    setIsReady(false);    setIsPlaying(false);    setCurrentTime(0);    setDuration(0);  }, []);  // ── Zoom ──────────────────────────────────────────────────────────────────  const handleZoom = React.useCallback((v: number[]) => {    const value = v[0];    setZoom(value);    wavesurferRef.current?.zoom(value);  }, []);  const zoomIn = React.useCallback(() => {    const next = Math.min(zoom * 1.5, maxZoom);    setZoom(next);    wavesurferRef.current?.zoom(next);  }, [zoom, maxZoom]);  const zoomOut = React.useCallback(() => {    const next = Math.max(zoom / 1.5, minZoom);    setZoom(next);    wavesurferRef.current?.zoom(next);  }, [zoom, minZoom]);  // ── Playback ──────────────────────────────────────────────────────────────  const togglePlay = React.useCallback(    () => wavesurferRef.current?.playPause(),    [],  );  const restart = React.useCallback(() => {    if (!wavesurferRef.current || !isReady) return;    wavesurferRef.current.setTime(0);    wavesurferRef.current.play();  }, [isReady]);  const handleVolume = React.useCallback((v: number[]) => {    const value = v[0];    setVolume(value);    setIsMuted(value === 0);    wavesurferRef.current?.setVolume(value);  }, []);  const toggleMute = React.useCallback(() => {    if (!wavesurferRef.current) return;    const next = !isMuted;    setIsMuted(next);    wavesurferRef.current.setVolume(next ? 0 : volume);  }, [isMuted, volume]);  const handleSeek = React.useCallback(    ([v]: number[]) => {      if (!wavesurferRef.current || !isReady) return;      wavesurferRef.current.seekTo(v);    },    [isReady],  );  // ── Derived ───────────────────────────────────────────────────────────────  const progress = duration > 0 ? currentTime / duration : 0;  // ── Render ────────────────────────────────────────────────────────────────  return (    <Card      className={cn(        "w-full px-0 border-none rounded-none bg-transparent",        className,      )}    >      <CardContent className=" space-y-3 p-0">        {/* Title */}        {title && (          <p className="text-sm font-medium text-foreground truncate">            {title}          </p>        )}        {/* Waveform + timeline */}        <div className="relative w-full rounded-sm overflow-hidden border border-border">          {!isReady && (            <div              className="absolute inset-0 z-10 flex items-center justify-center bg-card/80 backdrop-blur-[2px]"              style={{ minHeight: waveHeight }}            >              <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />            </div>          )}          <WavesurferPlayer            url={src}            waveColor={waveColor}            progressColor={progressColor}            height={waveHeight}            barWidth={barWidth}            barGap={barGap}            barRadius={barRadius}            minPxPerSec={defaultZoom}            fillParent            dragToSeek            hideScrollbar={false}            plugins={plugins}            onReady={handleReady}            onPlay={handlePlay}            onPause={handlePause}            onFinish={handleFinish}            onTimeupdate={handleTimeupdate}            onSeeking={handleSeeking}            onDestroy={handleDestroy}          />        </div>        {/* Seek bar */}        <div className="flex items-center gap-2">          <span className="text-[11px] tabular-nums text-muted-foreground w-10 text-right shrink-0">            {formatTime(currentTime)}          </span>          <Slider            className="flex-1"            value={[progress]}            min={0}            max={1}            step={0.001}            disabled={!isReady}            onValueChange={handleSeek}          />          <span className="text-[11px] tabular-nums text-muted-foreground w-10 shrink-0">            {formatTime(duration)}          </span>        </div>        {/* Controls */}        <div className="flex items-center justify-between gap-3 flex-wrap">          {/* Playback */}          <div className="flex items-center gap-1.5">            <Button              size="icon"              variant="ghost"              className="h-8 w-8 text-muted-foreground hover:text-foreground"              disabled={!isReady}              onClick={restart}              aria-label="Restart"            >              <RotateCcw size={15} />            </Button>            <Button              size="icon"              variant="secondary"              className="h-9 w-9"              disabled={!isReady}              onClick={togglePlay}              aria-label={isPlaying ? "Pause" : "Play"}            >              {isPlaying ? <Pause size={17} /> : <Play size={17} />}            </Button>          </div>          {/* Zoom */}          <div className="flex items-center gap-2 flex-1 max-w-50">            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              disabled={!isReady || zoom <= minZoom}              onClick={zoomOut}              aria-label="Zoom out"            >              <ZoomOut size={15} />            </Button>            <Slider              value={[zoom]}              min={minZoom}              max={maxZoom}              step={1}              disabled={!isReady}              onValueChange={handleZoom}              aria-label="Zoom"            />            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              disabled={!isReady || zoom >= maxZoom}              onClick={zoomIn}              aria-label="Zoom in"            >              <ZoomIn size={15} />            </Button>          </div>          {/* Volume */}          <div className="flex items-center gap-2 w-32">            <Button              size="icon"              variant="ghost"              className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"              onClick={toggleMute}              aria-label={isMuted ? "Unmute" : "Mute"}            >              {isMuted ? <VolumeX size={15} /> : <Volume2 size={15} />}            </Button>            <Slider              value={[isMuted ? 0 : volume]}              min={0}              max={1}              step={0.01}              onValueChange={handleVolume}              aria-label="Volume"            />          </div>        </div>      </CardContent>    </Card>  );}export default WaveTimeline;

Update the import paths to match your project setup.

Features

  • Timeline ruler — tick marks and time labels rendered above and/or below the waveform via the WaveSurfer Timeline plugin; fully themed via useCssVar.
  • Zoom — three-way zoom control: step buttons (×1.5) and a precise slider, all calling ws.zoom() at runtime.
  • Seek bar — a shadcn Slider synced to playback progress; click or drag the waveform directly thanks to dragToSeek.
  • Volume & mute — slider-based volume with a mute toggle that preserves the last set level.
  • Loading state — a spinner overlays the waveform while wave is being fetched and decoded.
  • Shadcn themingwaveColor and progressColor accept any CSS value including var(--*) tokens, resolved at runtime for the Canvas API via useCssVar.

Usage

import { WaveTimeline } from "@/components/waves-cn/wave-timeline"
<WaveTimeline url="/coastline.mp3" />

Examples

With dual timelines

Show both a top and bottom timeline ruler.

Loading...
import WaveTimeline from "@/components/waves-cn/wave-timeline";export default function WaveTimelineDualDemo() {  return (    <div className="w-full  mx-auto p-6">      <WaveTimeline        src="/coastline.mp3"        title="Dual Timeline"        topTimeline={{ height: 20, primaryLabelInterval: 5, timeInterval: 0.5 }}        bottomTimeline={{          height: 14,          primaryLabelInterval: 1,          timeInterval: 0.1,        }}      />    </div>  );}

No ruler

Pass topTimeline={false} to render the waveform and zoom controls without any timeline.

Loading...
import WaveTimeline from "@/components/waves-cn/wave-timeline";export default function WaveTimelineNoRulerExample() {  return (    <div className="w-full  mx-auto p-6">      <WaveTimeline src="/coastline.mp3" title="No Ruler" topTimeline={false} />    </div>  );}

Custom zoom range

<WaveTimeline 
  url="/coastline.mp3"
  defaultZoom={100}
  minZoom={20}
  maxZoom={1000}
/>

API Reference

WaveTimeline

PropTypeDefaultDescription
urlstring(Required) Wave file URL to load.
waveColorstringvar(--muted-foreground)Wave bar color. Accepts any CSS value including var(--*) tokens.
progressColorstringvar(--primary)Progress bar color. Accepts any CSS value including var(--*) tokens.
waveHeightnumber80Wave canvas height in px.
barWidthnumber2Bar width in px.
barGapnumber1Gap between bars in px.
barRadiusnumber2Bar border radius in px.
defaultZoomnumber50Initial zoom level in pixels per second.
minZoomnumber10Minimum zoom level.
maxZoomnumber500Maximum zoom level.
topTimelineTimelineOptions | false{}Top timeline config. Pass false to hide.
bottomTimelineTimelineOptions | falsefalseBottom timeline config. Pass {} or options to show.
classNamestringRoot element class.
styleCSSPropertiesRoot element inline style.

TimelineOptions

OptionTypeDefault (top)Default (bottom)Description
heightnumber2014Height of the timeline bar in px.
timeIntervalnumber0.50.1Seconds between each tick mark.
primaryLabelIntervalnumber51Seconds between labeled ticks.
secondaryLabelIntervalnumber1undefinedSeconds between secondary labeled ticks.
fontSizestring"11px""10px"CSS font size for labels.

On this page