Wave Speed

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

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

Installation

Install the required shadcn/ui primitives.

npx shadcn@latest add button slider switch label skeleton

Add the component via jsrepo.

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

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 slider switch label 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-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/ui/wave-speed"
<WaveSpeed url="/coastline.mp3" />

Examples

Custom Range

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

Loading...
import { WaveSpeed } from "@/components/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>  );}
<WaveSpeed
  url="/audio/podcast.mp3"
  minSpeed={0.5}
  maxSpeed={2}
  defaultSpeed={1}
  step={0.25}
/>

Custom Colors

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

Loading...
import { WaveSpeed } from "@/components/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>  );}
<WaveSpeed
  url="/coastline.mp3"
  waveColor="var(--chart-1)"
  progressColor="var(--chart-2)"
  barWidth={4}
  barGap={3}
  barRadius={8}
/>

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