Wave Speed

A wave player with variable playback speed control, built on wavesurfer.js.

Loading...
import { WaveSpeed } from "@/components/waves-cn/wave-speed";export default function WaveSpeedDemo() {  return (    <div className="w-full  mx-auto p-6">      <WaveSpeed url="/coastline.mp3" />    </div>  );}

Installation

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

Install the peer dependency.

npm install wavesurfer.js

Install the required shadcn primitives.

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

import { useState, useCallback, useRef, type CSSProperties } from "react";import { Button } from "@/components/ui/button";import { Slider } from "@/components/ui/slider";import { Switch } from "@/components/ui/switch";import { Label } from "@/components/ui/label";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 WaveSpeed component */export type WaveSpeedProps = {  /** Audio file URL to load */  url: string;  /** Audio 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;  /** Audio canvas height in px @default 64 */  audioHeight?: 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;  /** Minimum playback speed @default 0.25 */  minSpeed?: number;  /** Maximum playback speed @default 4 */  maxSpeed?: number;  /** Initial playback speed @default 1 */  defaultSpeed?: number;  /** Slider step increment @default 0.25 */  step?: number;  /** Root element class */  className?: string;  style?: CSSProperties;};/** * Audio player with variable playback speed control */export function WaveSpeed({  url,  waveColor,  progressColor,  audioHeight,  barWidth,  barGap,  barRadius,  minSpeed = 0.25,  maxSpeed = 4,  defaultSpeed = 1,  step = 0.25,  className,  style,}: WaveSpeedProps) {  const wavesurferRef = useRef<WaveSurfer | null>(null);  const [isReady, setIsReady] = useState(false);  const [isPlaying, setIsPlaying] = useState(false);  const [speed, setSpeed] = useState(defaultSpeed);  const [preservePitch, setPreservePitch] = useState(true);  const togglePlay = useCallback(() => wavesurferRef.current?.playPause(), []);  const handleSpeedChange = useCallback(    ([value]: number[]) => {      setSpeed(value);      wavesurferRef.current?.setPlaybackRate(value, preservePitch);    },    [preservePitch],  );  const handlePreservePitch = useCallback(    (checked: boolean) => {      setPreservePitch(checked);      wavesurferRef.current?.setPlaybackRate(speed, checked);    },    [speed],  );  return (    <div className={cn("w-full space-y-4", className)} style={style}>      {/* WavesurferPlayer renders the container div and wires up all events */}      <div className="w-full rounded-md overflow-hidden bg-muted/40">        <WavesurferPlayer          url={url}          waveColor={waveColor}          progressColor={progressColor}          height={audioHeight}          barWidth={barWidth}          barGap={barGap}          barRadius={barRadius}          dragToSeek          onReady={(ws) => {            wavesurferRef.current = ws;            setIsReady(true);          }}          onPlay={() => setIsPlaying(true)}          onPause={() => setIsPlaying(false)}          onFinish={() => setIsPlaying(false)}          onDestroy={() => {            wavesurferRef.current = null;            setIsReady(false);          }}        />      </div>      <div className="flex flex-wrap items-center gap-4">        <Button          size="icon"          onClick={togglePlay}          disabled={!isReady}          aria-label={isPlaying ? "Pause" : "Play"}        >          {isPlaying ? (            <Pause className="size-4" />          ) : (            <Play className="size-4" />          )}        </Button>        <Button          variant="outline"          className="text-sm text-muted-foreground tabular-nums shrink-0 pointer-events-none"        >          Playback rate:{" "}          <span className="font-medium text-foreground">            {speed.toFixed(2)}          </span>          x        </Button>        <div className="flex items-center gap-3 flex-1 min-w-48">          <span className="text-sm text-muted-foreground shrink-0">            {minSpeed}x          </span>          <Slider            min={minSpeed}            max={maxSpeed}            step={step}            value={[speed]}            onValueChange={handleSpeedChange}            disabled={!isReady}            className="flex-1"          />          <span className="text-sm text-muted-foreground shrink-0">            {maxSpeed}x          </span>        </div>        <div className="flex items-center gap-2">          <Switch            id="preserve-pitch"            checked={preservePitch}            onCheckedChange={handlePreservePitch}            disabled={!isReady}          />          <Label            htmlFor="preserve-pitch"            className="text-sm text-muted-foreground cursor-pointer"          >            Preserve pitch          </Label>        </div>      </div>    </div>  );}export default WaveSpeed;

Update the import paths to match your project setup.

Features

  • Continuous speed slider — smoothly adjust playback rate between 0.25x and 4x with fine-grained step control.
  • Preserve pitch — toggle pitch preservation independently from speed so the wave stays natural at higher rates.
  • Live rate display — a read-only badge shows the current playback rate as you drag the slider.
  • Drag to seek — click or drag anywhere on the waveform to jump to that position.
  • No re-render on speed change — speed is applied imperatively via setPlaybackRate, so the wave is never destroyed or recreated when changing speed.

Usage

import { WaveSpeed } from "@/components/waves-cn/wave-speed"

Examples

Custom Range

Narrow the speed range for a more focused control, e.g. podcast listening speeds.

Loading...
import { WaveSpeed } from "@/components/waves-cn/wave-speed";export default function WaveSpeedPodcast() {  return (    <div className="w-full  mx-auto p-6">      <WaveSpeed        url="/coastline.mp3"        minSpeed={0.5}        maxSpeed={2}        defaultSpeed={1}        step={0.25}      />    </div>  );}

Custom Colors

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

Loading...
import { WaveSpeed } from "@/components/waves-cn/wave-speed";export default function WaveSpeedCustomWave() {  return (    <div className="w-full  mx-auto p-6">      <WaveSpeed        url="/coastline.mp3"        waveColor="var(--chart-1)"        progressColor="var(--chart-2)"        barWidth={4}        barGap={3}        barRadius={8}      />    </div>  );}

API Reference

WaveSpeed

PropTypeDefaultDescription
urlstringWave file URL to load.
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.
minSpeednumber0.25Minimum playback speed.
maxSpeednumber4Maximum playback speed.
defaultSpeednumber1Initial playback speed.
stepnumber0.25Slider step increment.
classNamestringRoot element class.
styleCSSPropertiesRoot element inline style.

On this page