EmpireUI
Get Pro
← Blog9 min read#audio player#react#web audio

Audio Player in React: Play, Seek, Volume and Waveform Display

Build a fully custom React audio player with play/pause, seek, volume control, and a real-time waveform visualizer using the Web Audio API - no third-party lib needed.

colorful audio waveform visualization on dark background abstract

Why Build Your Own React Audio Player?

The native <audio> element is fine. It works everywhere, it's accessible out of the box, and you don't have to write a single line of JavaScript to get basic playback. But the moment a designer hands you a custom music player UI - waveform scrubber, animated bars, volume knob styled to match the app's glassmorphism theme - the built-in controls become useless. You're building from scratch.

Third-party packages like react-player or wavesurfer.js solve part of the problem, but they come with opinions. wavesurfer.js (as of v7, released in 2023) is pretty solid, but it ships its own rendering engine, its own event system, and a bundle cost you might not want. In practice, the Web Audio API plus a <canvas> element gets you 90% of the way there with zero extra dependencies - and you understand every pixel of what you're drawing.

This guide builds a complete player: play/pause toggle, a clickable seek bar, live volume control, and a real-time frequency-bar waveform. Each piece is composable. Take just the seek bar, take just the waveform - it's all self-contained hooks and components. If you want to wrap the finished thing in a premium UI shell, browse components on Empire UI; there are dark-mode card variants and glassmorphism surfaces that pair with this player instantly.

One more thing - everything here is React 18 with TypeScript. The patterns (refs, effects, custom hooks) carry straight into Next.js App Router too.

Wiring Up the HTML Audio Element with React Refs

Start with the simplest possible thing: a ref pointing at an <audio> element and a bit of state tracking playback. Don't reach for a library to manage play/pause; the HTMLAudioElement API already has .play() and .pause() and fires timeupdate events you can listen to directly.

// useAudioPlayer.ts
import { useRef, useState, useEffect, useCallback } from 'react';

export function useAudioPlayer(src: string) {
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolumeState] = useState(1);

  useEffect(() => {
    const audio = new Audio(src);
    audioRef.current = audio;

    audio.addEventListener('timeupdate', () => setCurrentTime(audio.currentTime));
    audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
    audio.addEventListener('ended', () => setIsPlaying(false));

    return () => {
      audio.pause();
      audio.src = '';
    };
  }, [src]);

  const togglePlay = useCallback(() => {
    const audio = audioRef.current;
    if (!audio) return;
    if (isPlaying) {
      audio.pause();
      setIsPlaying(false);
    } else {
      audio.play();
      setIsPlaying(true);
    }
  }, [isPlaying]);

  const seek = useCallback((time: number) => {
    if (!audioRef.current) return;
    audioRef.current.currentTime = time;
    setCurrentTime(time);
  }, []);

  const setVolume = useCallback((v: number) => {
    if (!audioRef.current) return;
    audioRef.current.volume = v;
    setVolumeState(v);
  }, []);

  return { isPlaying, currentTime, duration, volume, togglePlay, seek, setVolume };
}

A few things worth noting here: creating the Audio object programmatically (rather than rendering <audio> in JSX) means you're not fighting React's reconciler over the DOM node. You own the lifecycle. The cleanup function on the effect is important - pause and clear src so the browser releases the network connection and buffer.

Worth noting: loadedmetadata fires once the browser knows the duration. On some streams it fires late or not at all, so always guard audio.duration against NaN before displaying it to users.

Honestly, this hook is enough for 80% of use cases. Drop it into a component with a button and an <input type="range"> and you have a working player in about 30 lines.

Building the Seek Bar and Volume Control

The seek bar is just a range input whose value tracks currentTime and whose max is duration. The trick is preventing a feedback loop: while the user is dragging, you don't want React re-renders from timeupdate events to reset the thumb mid-drag.

// SeekBar.tsx
import { useRef } from 'react';

interface SeekBarProps {
  currentTime: number;
  duration: number;
  onSeek: (time: number) => void;
}

export function SeekBar({ currentTime, duration, onSeek }: SeekBarProps) {
  const isDragging = useRef(false);

  const pct = duration > 0 ? (currentTime / duration) * 100 : 0;

  return (
    <div className="relative w-full h-1.5 bg-white/20 rounded-full cursor-pointer">
      {/* progress fill */}
      <div
        className="absolute inset-y-0 left-0 bg-violet-500 rounded-full"
        style={{ width: `${pct}%` }}
      />
      <input
        type="range"
        min={0}
        max={duration || 100}
        step={0.01}
        value={currentTime}
        className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
        onMouseDown={() => { isDragging.current = true; }}
        onMouseUp={(e) => {
          isDragging.current = false;
          onSeek(Number(e.currentTarget.value));
        }}
        onChange={(e) => {
          if (isDragging.current) onSeek(Number(e.currentTarget.value));
        }}
      />
    </div>
  );
}

That invisible range input layered over the styled track is a pattern you'll see in most production players. The input handles keyboard navigation and touch events natively - you get free accessibility without re-implementing arrow-key stepping yourself.

Volume control follows the same pattern. A range input clamped 0–1, stepped at 0.01. In practice, a logarithmic curve feels more natural to humans: audio.volume = Math.pow(sliderValue, 2) maps the midpoint (0.5 on the slider) to roughly 25% of linear gain, which is about where people expect a "medium" volume to land. Add a mute toggle button and you're done.

// VolumeControl.tsx
interface VolumeControlProps {
  volume: number;
  onVolumeChange: (v: number) => void;
}

export function VolumeControl({ volume, onVolumeChange }: VolumeControlProps) {
  return (
    <div className="flex items-center gap-2">
      <button
        onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
        className="text-white/70 hover:text-white transition-colors"
        aria-label={volume === 0 ? 'Unmute' : 'Mute'}
      >
        {volume === 0 ? '🔇' : '🔊'}
      </button>
      <input
        type="range" min={0} max={1} step={0.01}
        value={volume}
        onChange={(e) => onVolumeChange(Number(e.currentTarget.value))}
        className="w-24 accent-violet-500"
        aria-label="Volume"
      />
    </div>
  );
}

That accent-violet-500 Tailwind class (available since Tailwind v3.1) styles the native range thumb in Chromium and Firefox without custom CSS. Quick, dirty, effective.

Real-Time Waveform Visualizer with the Web Audio API

This is where things get interesting. The Web Audio API exposes an AnalyserNode that gives you a snapshot of the audio frequency data at any moment - up to 1024 frequency buckets in real time. Draw those to a <canvas> element on every animation frame and you get a live equalizer-style bar visualizer.

// useWaveform.ts
import { useRef, useEffect } from 'react';

export function useWaveform(
  audioElement: HTMLAudioElement | null,
  canvasRef: React.RefObject<HTMLCanvasElement>
) {
  const contextRef = useRef<AudioContext | null>(null);
  const analyserRef = useRef<AnalyserNode | null>(null);
  const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
  const rafRef = useRef<number>(0);

  useEffect(() => {
    if (!audioElement || !canvasRef.current) return;

    // AudioContext must be created after a user gesture (browser policy)
    const ctx = new AudioContext();
    const analyser = ctx.createAnalyser();
    analyser.fftSize = 256; // 128 frequency bins
    const source = ctx.createMediaElementSource(audioElement);
    source.connect(analyser);
    analyser.connect(ctx.destination);

    contextRef.current = ctx;
    analyserRef.current = analyser;
    sourceRef.current = source;

    const canvas = canvasRef.current;
    const canvasCtx = canvas.getContext('2d')!;
    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);

    function draw() {
      rafRef.current = requestAnimationFrame(draw);
      analyser.getByteFrequencyData(dataArray);

      const { width, height } = canvas;
      canvasCtx.clearRect(0, 0, width, height);

      const barWidth = (width / bufferLength) * 2.5;
      let x = 0;

      for (let i = 0; i < bufferLength; i++) {
        const barHeight = (dataArray[i] / 255) * height;
        // violet → pink gradient per bar
        const hue = 260 + (i / bufferLength) * 60;
        canvasCtx.fillStyle = `hsl(${hue}, 80%, 60%)`;
        canvasCtx.fillRect(x, height - barHeight, barWidth, barHeight);
        x += barWidth + 1;
      }
    }
    draw();

    return () => {
      cancelAnimationFrame(rafRef.current);
      ctx.close();
    };
  }, [audioElement]);
}

A few things trip people up here. First: browsers require a user gesture before an AudioContext can start. You'll see a state: 'suspended' warning in the console until the user clicks play. Call ctx.resume() inside your togglePlay handler to fix that. Second: createMediaElementSource can only be called once per HTMLAudioElement - calling it again throws. Keep the source node in a ref and skip the setup if it already exists.

The fftSize of 256 gives you 128 frequency bins, which draws about 128 bars. That's enough resolution to look great without hammering the CPU. If you go to 2048 (1024 bins) it looks more detailed but at 60fps you're doing a lot of work - profile it on a mid-range Android before shipping.

Look, the HSL gradient loop (hue 260 to 320, violet to pink) is a deliberate design choice. It makes the visualizer feel alive without needing a design system. If your app uses cyberpunk or vaporwave colors from Empire UI, just swap those hue values to match your theme tokens.

Wire everything together with the canvas element in JSX: ``tsx // AudioPlayer.tsx (partial) const canvasRef = useRef<HTMLCanvasElement>(null); const { isPlaying, currentTime, duration, volume, togglePlay, seek, setVolume } = useAudioPlayer(props.src); useWaveform(audioRef.current, canvasRef); // in render: <canvas ref={canvasRef} width={640} height={80} className="w-full rounded-lg" /> ``

Putting It All Together: the Full AudioPlayer Component

Here's the full component shell that composes the hooks and sub-components. It's intentionally unstyled beyond utility classes so you can drop it into any design system - including one of Empire UI's glassmorphism components as the container card.

// AudioPlayer.tsx
import { useRef } from 'react';
import { useAudioPlayer } from './useAudioPlayer';
import { useWaveform } from './useWaveform';
import { SeekBar } from './SeekBar';
import { VolumeControl } from './VolumeControl';

function formatTime(s: number) {
  const m = Math.floor(s / 60);
  const sec = Math.floor(s % 60).toString().padStart(2, '0');
  return `${m}:${sec}`;
}

interface AudioPlayerProps {
  src: string;
  title?: string;
}

export function AudioPlayer({ src, title }: AudioPlayerProps) {
  const { isPlaying, currentTime, duration, volume,
          togglePlay, seek, setVolume, audioRef } =
    useAudioPlayer(src);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  useWaveform(audioRef.current, canvasRef);

  return (
    <div className="flex flex-col gap-3 p-4 bg-white/10 backdrop-blur-md
                    border border-white/20 rounded-2xl">
      {title && (
        <p className="text-sm font-medium text-white truncate">{title}</p>
      )}

      <canvas
        ref={canvasRef}
        width={640}
        height={64}
        className="w-full rounded-md bg-white/5"
      />

      <SeekBar currentTime={currentTime} duration={duration} onSeek={seek} />

      <div className="flex items-center justify-between">
        <button
          onClick={togglePlay}
          className="w-10 h-10 flex items-center justify-center
                     bg-violet-600 hover:bg-violet-500 rounded-full
                     text-white transition-colors"
          aria-label={isPlaying ? 'Pause' : 'Play'}
        >
          {isPlaying ? '⏸' : '▶'}
        </button>

        <span className="text-xs text-white/60 tabular-nums">
          {formatTime(currentTime)} / {formatTime(duration)}
        </span>

        <VolumeControl volume={volume} onVolumeChange={setVolume} />
      </div>
    </div>
  );
}

That bg-white/10 backdrop-blur-md border border-white/20 wrapper is the glassmorphism card pattern from Empire UI baked straight into the player. Put this on a gradient background and the whole thing looks production-quality immediately.

Worth noting: the tabular-nums class on the timestamp prevents the layout from jumping as the numbers change. Tiny detail, big difference. Designers notice this.

For accessibility: add role="region" and aria-label="Audio player" to the outer div. The play/pause button already has an aria-label that updates. The seek bar's invisible range input handles keyboard navigation for free. You should still test with a screen reader - VoiceOver on macOS and NVDA on Windows are both free and take five minutes.

Styling, Theming, and Making It Look Good

The functional player above is deliberately plain. Making it look genuinely great takes another pass. A few high-impact changes: add a box-shadow: 0 0 40px rgba(139, 92, 246, 0.3) glow under the play button (or use Empire UI's box shadow generator to dial in exact values without guessing), animate the play button with a subtle pulse when playing, and use transition-transform on the waveform canvas to scale it up 1.01x when audio is playing.

If you want the waveform bars to look smoother, apply a vertical border-radius to each bar. In the canvas context you can't use CSS border-radius directly, but you can use ctx.roundRect() (supported in Chrome 99+ and Firefox 112+) or fall back to arc-based rounding for older targets.

Dark-mode is trivial here because the glassmorphism palette already works on dark backgrounds. For light mode you'd flip to bg-black/5 backdrop-blur-md border border-black/10 and change the waveform hues to something with more saturation to stay visible against a pale background.

That said, theming across your whole app is where a design system pays off. Empire UI's gradient generator lets you pull a background gradient that makes the blur effect look dramatic - copy the CSS output directly into your layout's background property and your player immediately sits inside something visually interesting.

Quick aside: if you're building a music app or podcast player, consider the Media Session API. It takes about 20 lines to wire up and gives you native OS playback controls (lock screen, AirPods buttons, taskbar) which is a massive UX win on mobile.

Performance, Edge Cases, and What to Watch Out For

The requestAnimationFrame loop in the waveform hook runs at 60fps regardless of whether audio is playing. That's wasted cycles when the player is paused. Add a isPlaying flag (passed into the hook) and skip calling requestAnimationFrame when paused - just draw a flat line or nothing. Your CPU will thank you, especially on battery-constrained mobile devices.

CORS is the other common gotcha. If your audio files are on S3 or a CDN, you need crossOrigin="anonymous" on the HTMLAudioElement *before* you set its src. Do it the wrong way round and you'll get a tainted MediaElement error when you try to connect it to the Web Audio API. Order matters: set crossOrigin first, then src.

Stream sources (radio URLs, HLS) need special handling. duration will be Infinity for a live stream - format it as "LIVE" instead of a timestamp. Seeking doesn't make sense on a live stream either, so hide or disable the seek bar based on a isLive flag you derive from duration === Infinity.

In 2024, Safari shipped full AudioContext autoplay unlock via user gesture, bringing it in line with Chromium. Before that, iOS would silently suspend the context even after a tap on certain builds. If you're supporting older iOS (pre-17.4), call ctx.resume() explicitly in your play handler and check ctx.state before calling it - it's a no-op if already running, and it won't throw.

Honestly, the hardest part of this whole thing isn't the code - it's the browser policy maze around autoplay and CORS. Once you've navigated those two things, everything else is just UI work. And for the UI work, Empire UI has you covered.

FAQ

Can I use the Web Audio API AnalyserNode with a streaming audio source?

Yes, but you need to connect the stream to a MediaElementAudioSourceNode and handle the case where duration is Infinity. Seeking won't work on live streams, so hide the seek bar and show a 'LIVE' badge instead.

Why does my AudioContext stay in 'suspended' state?

Browsers block audio from auto-playing without a user gesture. Call audioContext.resume() inside your play button's click handler and the context will move to 'running' state immediately.

How do I fix 'Cannot create MediaElementSource on an AudioElement that already has a source'?

You called createMediaElementSource() more than once on the same element. Store the source node in a ref and skip the setup if the ref is already populated - one source node per audio element, full stop.

Is wavesurfer.js worth using instead of the Web Audio API directly?

If you need static waveform rendering from a pre-decoded file (not real-time), wavesurfer.js v7 is excellent and saves a lot of canvas work. For live frequency-bar visualizers, rolling your own with AnalyserNode keeps the bundle smaller and gives you full control over the visual.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Audio Visualizer in React: Web Audio API + Canvas = Waveform MagicSpatial UI Design in 2026: Vision Pro, Depth and the Glass EraLanding Page Design Patterns in 2026: Above the Fold, Hero, CTAMobile-First UI Design: 48px Touch Targets, Thumb Zones, Safe Areas