# React Audio Player with Waveform: Scrub, Seek, Peaks

> Build a React audio player with waveform scrubbing for podcasts, chat voice notes and call logs, with keyboard seek, precomputed peaks and no layout shift.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-audio-player-with-waveform

## TL;DR

A React audio player with waveform needs bars you can click, drag and move with the arrow keys, a seek slider that screen readers announce as a time, and a waveform that keeps its size while the real peaks load. Precompute peaks on the server for long files, because decoding in the browser holds the whole recording in memory. The Wingo UI Audio Player ships a podcast card, a chat voice message and a call-log bar on top of the useMediaControls hook.

You need a React audio player with waveform bars: a podcast episode on a landing page, voice notes in a chat, or recorded calls in a CRM. The native `<audio controls>` element plays the file, but it looks different in every browser, shows no waveform and is hard to fit inside a chat bubble. A waveform library draws the bars; the hard parts are still yours: scrubbing on a phone, keyboard seeking, what a screen reader hears, and the jump in layout when the real waveform arrives. This guide covers those parts with the Wingo UI [Audio Player](https://wingo-ui.com/components/audio-player) and the [useMediaControls](https://wingo-ui.com/components/use-media-controls) hook it is built on.

## What should a React waveform audio player handle?

Seven things. A React waveform audio player that holds up in production needs:

1. **Click, tap and drag to seek** on the bars, with the time following the finger while you drag.
2. **Keyboard seeking** with the arrow keys, Page Up and Page Down, Home and End.
3. **A slider that speaks a time.** Screen readers announce the position as text, such as "1:12 of 4:05", instead of a raw count of seconds.
4. **Stable layout.** The waveform has a fixed height and placeholder bars from the first render, so nothing shifts when the decoded peaks arrive.
5. **Peaks that scale.** Short clips can be decoded in the browser; long ones need precomputed peaks.
6. **One player at a time.** Starting a voice note pauses the one that was playing.
7. **Speed, lock screen and headphone controls**, because people listen to podcasts at 1.5x with the phone in a pocket.

## How do the waveform options compare?

The main choice is a waveform library you wire into your own UI, or a finished player component. The table compares one of each, with facts from each project's public docs as of October 2026.

| Option | What it is | Peaks | Player UI |
|---|---|---|---|
| [wavesurfer.js](https://wavesurfer.xyz/) with [@wavesurfer/react](https://www.npmjs.com/package/@wavesurfer/react) | BSD-3-Clause waveform library; the React package exports `WavesurferPlayer` and `useWavesurfer` | Decodes in the browser, or takes precomputed `peaks` and `duration` | You build the buttons, time and layout; plugins add regions, timelines and spectrograms |
| [Wingo UI Audio Player](https://wingo-ui.com/components/audio-player) | Pro source component, `npx wingo-ui@latest add audio-player` | Decodes in the browser once visible, or takes a `peaks` array | Card, compact voice message and minimal call-log variants, speed, volume, download, lock screen |

wavesurfer.js is the better choice when the waveform is the product: editing, regions, zooming or a spectrogram. For a podcast player react component or a voice message player react component, a finished player is less work: it already has the controls, the keyboard map and the loading and error states.

## What props does the Audio Player need?

Only `src`. Everything else has a default: the `card` variant, the neutral tone, 15 second skip buttons, a speed menu from 0.5x to 2x and as many bars as fit the width.

```bash
npx wingo-ui@latest add audio-player
```

```tsx
import { AudioPlayer } from "@/components/ui/audio-player";

export function Episode() {
  return (
    <AudioPlayer
      src="/episodes/12.mp3"
      title="Sales tax for small businesses"
      subtitle="Summit Podcast · Ep. 12 · Sep 24, 2026"
      cover="/episodes/12.jpg"
      downloadable
    />
  );
}
```

The props you will change most often are `variant` (`card`, `compact` or `minimal`), `tone` or `color` for the played bars and the play button, `skipSeconds` (0 hides the skip buttons), `rates` for the speed list, and `barWidth`, `barGap` and `waveformHeight` for denser or taller bars. `title`, `subtitle` and `cover` also become the lock-screen metadata while the file plays.

Press play, then drag across the bars. With a mouse, hover to see the time above the cursor; with the keyboard, Tab to the waveform and use the arrow keys.

> Live demo (Audio Player): Press play, then drag along the waveform to scrub. Tab to the bars and press the Right arrow to jump 5 seconds, Page Up for 30. Try it at [Audio Player](https://wingo-ui.com/components/audio-player) and install it with `npx wingo-ui@latest add audio-player`.

## How do I avoid layout jank while the waveform loads?

Reserve the space and fill it with bars from the first render. The waveform is 40px high in the card and 28px in the compact player (`waveformHeight` overrides both), so its box never changes size. Until real peaks exist, the player draws `placeholderPeaks(src, count)`: stable, speech-like bars seeded from the url, identical on the server and the client, so there is no hydration mismatch. On the server the bars spread over the width; after mount a ResizeObserver counts how many `barWidth + barGap` bars fit and redraws.

When the decoded peaks arrive, the new bars cross-fade over the placeholders. The played part is the same bars in the tone, clipped with `clip-path` from a motion value, so playback repaints instead of re-rendering React on every frame. The time text uses tabular numbers and switches to an hours format for recordings of an hour or more, so its width does not jitter.

## Should I decode peaks in the browser or precompute them?

Decode short files, precompute long ones. Without a `peaks` prop the player waits until it is within 200px of the viewport, fetches the file, decodes it with an `OfflineAudioContext` and keeps the loudest sample per bucket (`decodePeaks` caches the result per url and bar count). That is fine for a 30 second voice note. The [wavesurfer.js performance notes](https://wavesurfer.xyz/docs/performance/) explain the limit: Web Audio decoding holds the whole file as raw PCM in memory, and a 60-minute stereo track can pass 500 MB. Decoding also needs the file on your origin or served with CORS; a blocked file stays on the placeholder bars.

For podcasts and call recordings, generate peaks once when the file is uploaded. BBC's [audiowaveform](https://github.com/bbc/audiowaveform) writes them as JSON; this is the command from the [wavesurfer.js peaks guide](https://wavesurfer.xyz/docs/peaks/):

```bash
audiowaveform -i 12.mp3 -o 12.waveform.json --pixels-per-second 20 --bits 8
```

The JSON `data` array holds interleaved min and max pairs per channel ([data format](https://github.com/bbc/audiowaveform/blob/master/doc/DataFormat.md)). The player wants one number per bar from 0 to 1, in any length, so fold each pair into its larger absolute value, downsample to a few hundred buckets and normalize:

```ts
// scripts/peaks.ts: npx tsx scripts/peaks.ts 12.waveform.json public/episodes/12.peaks.json
import { readFileSync, writeFileSync } from "node:fs";

type Waveform = { channels?: number; length: number; data: number[] };

const [input, output] = process.argv.slice(2);
const json = JSON.parse(readFileSync(input, "utf8")) as Waveform;
const channels = json.channels ?? 1;
const buckets = Math.min(400, json.length);
const peaks = new Array<number>(buckets).fill(0);

for (let i = 0; i < json.length; i++) {
  const bucket = Math.floor((i * buckets) / json.length);
  for (let c = 0; c < channels; c++) {
    const at = (i * channels + c) * 2;
    const value = Math.max(Math.abs(json.data[at]), Math.abs(json.data[at + 1]));
    if (value > peaks[bucket]) peaks[bucket] = value;
  }
}

const loudest = peaks.reduce((max, value) => Math.max(max, value), 0) || 1;
writeFileSync(output, JSON.stringify(peaks.map((value) => Math.round((value / loudest) * 1000) / 1000)));
```

Then read the file in a server component and pass it down. The player skips decoding and shows the real waveform in the server HTML:

```tsx
import { readFile } from "node:fs/promises";
import path from "node:path";
import { AudioPlayer } from "@/components/ui/audio-player";

export default async function EpisodePage() {
  const file = path.join(process.cwd(), "public/episodes/12.peaks.json");
  const peaks = JSON.parse(await readFile(file, "utf8")) as number[];

  return <AudioPlayer src="/episodes/12.mp3" peaks={peaks} title="Sales tax for small businesses" />;
}
```

## How do I build a voice message player in React?

Use `variant="compact"`: play, bars, time and a speed chip that cycles 1x, 1.5x and 2x on each tap, sized for a chat bubble. Put an avatar or a mic icon in `leading`. `exclusive` is on by default, so starting one voice note pauses every other exclusive player on the page.

```tsx
import { AudioPlayer } from "@/components/ui/audio-player";
import { Avatar } from "@/components/ui/avatar";

export function VoiceNote({ src, from }: { src: string; from: { name: string; avatar?: string } }) {
  return (
    <div className="max-w-[300px] rounded-[22px] rounded-bl-[6px] bg-surface px-3 py-2.5 shadow-raised">
      <AudioPlayer
        variant="compact"
        src={src}
        leading={<Avatar src={from.avatar} name={from.name} size="sm" />}
        labels={{ play: "Listen", pause: "Stop" }}
      />
    </div>
  );
}
```

The compact player shows the length before it starts and the position after. Recording the voice note is the other half: the [Voice Input](https://wingo-ui.com/components/voice-input) records with MediaRecorder and hands you the Blob, and the [React speech to text input guide](https://wingo-ui.com/blog/react-speech-to-text-input) covers the mic, the live transcript and the server fallback.

One trap with fresh recordings: Chrome's MediaRecorder has long written WebM files without a duration ([Chromium issue 642012](https://crbug.com/642012)), so the audio element can report `Infinity`. Since Chrome 140 a recording made in one piece carries the duration, but one recorded with a timeslice or `requestData()` calls still does not ([Addpipe, updated June 2026](https://blog.addpipe.com/duration-in-webm-videos-produced-by-chrome/)). The Voice Input records in one-second chunks, so expect the missing duration with its Blobs. The Audio Player treats an infinite duration as a live stream: it shows a Live badge and turns off seeking. Patch the duration in the recorded file before you play it, or play the uploaded file once the server has stored it.

## How do I show recorded calls in a list?

Use `variant="minimal"`: play, a thin scrubbable bar, the time and an optional download, small enough for a table row or an activity feed. It drops the waveform on purpose, because thirty rows of bars are noise. `downloadable` adds the download button, and `fileName` names the saved file.

```tsx
<AudioPlayer variant="minimal" size="sm" src={call.recordingUrl} downloadable fileName={`Call ${call.contact}`} />
```

> Live demo (Audio Player): A recorded call in a CRM row: play it, drag the thin bar to seek and use the download button. Try it at [Audio Player](https://wingo-ui.com/components/audio-player) and install it with `npx wingo-ui@latest add audio-player`.

The [React CRM template](https://wingo-ui.com/blog/react-crm-template) is a full screen where call rows like this belong.

## How do I make an audio waveform accessible?

Treat the waveform as a seek slider. The player renders it with `role="slider"`, the label "Seek", `aria-valuemin` and `aria-valuemax` in seconds, and `aria-valuetext` such as "1:12 of 4:05", the same idea as the W3C [media seek slider example](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/), which converts seconds to minutes and seconds so nobody hears a raw number.

On the waveform, Left and Right (or Up and Down) seek 5 seconds, Page Up and Page Down 30, Home and End jump to the start or end, and Space, k or Enter play and pause. Anywhere in the player, j and l seek 10 seconds, m mutes, 0 to 9 jump to 0% through 90% and the angle brackets change the speed. Every button keeps a 44px touch target on phones, the volume slider hides on touch screens where the hardware keys do the job, and the speed menu opens as a bottom sheet below 768px. A missing file shows a message with a retry instead of a dead button. All strings live in `labels` and merge over `DEFAULT_AUDIO_PLAYER_LABELS`.

## How do I build a custom layout with useMediaControls?

Use the hook with the exported `Waveform` part, which makes this a React waveform component you can drop into any layout. `useMediaControls` returns a `ref` for the `<audio>` element, state such as `playing`, `duration` and `second` (whole seconds, at most one render a second), motion values for `progress` and `buffered`, and actions such as `toggle`, `seek`, `seekBy`, `seekToFraction` and `setRate`. `handleMediaKey` gives any root the same keyboard map.

```tsx
"use client";

import { Waveform } from "@/components/ui/audio-player";
import { formatMediaTime, handleMediaKey, useMediaControls } from "@/hooks/use-media-controls";

export function Clip({ src, peaks }: { src: string; peaks: number[] }) {
  // destructure ref so the react compiler lint does not treat the whole object as a ref
  const { ref, ...media } = useMediaControls({ exclusive: true });

  return (
    <div onKeyDown={(event) => handleMediaKey(event, media)} className="flex items-center gap-3">
      <audio ref={ref} src={src} preload="metadata" />
      <button type="button" onClick={media.toggle} className="h-11 rounded-full px-4">
        {media.playing ? "Pause" : "Play"}
      </button>
      <Waveform
        peaks={peaks}
        progress={media.progress}
        onSeek={media.seekToFraction}
        formatBubble={(fraction) => formatMediaTime(fraction * media.duration)}
        aria-label="Seek"
        aria-valuetext={`${formatMediaTime(media.second)} of ${formatMediaTime(media.duration)}`}
        className="flex-1"
      />
    </div>
  );
}
```

Pass `aria-label` and `aria-valuetext` yourself here: with a motion value for `progress`, `Waveform` cannot compute a numeric value on its own. If you only need to react to playback, `AudioPlayer` has `setApi`, which hands you the same controls without a ref, for example to highlight the current sentence of a transcript and seek when someone taps a line.

> Live demo (useMediaControls): Play the podcast and the voice notes: the bar moves every frame while the time changes once a second. Starting one voice note pauses the other when exclusive is on. Try it at [useMediaControls](https://wingo-ui.com/components/use-media-controls) and install it with `npx wingo-ui@latest add use-media-controls`.

## What are the common pitfalls?

- **A CORS-blocked file.** Decoding needs `fetch` to read the bytes. Files on another domain without CORS headers play fine and keep the placeholder bars. Pass `peaks` or serve the file with CORS.
- **Decoding long recordings.** Precompute peaks for anything longer than a few minutes, or the tab pays for the full PCM in memory.
- **`preload="none"` without a duration.** The player shows `--:--` until the first play. Keep `metadata` unless you render hundreds of rows.
- **Two players talking at once.** Leave `exclusive` on for chat threads and episode lists.
- **Fresh WebM Blobs in Chrome.** Chunked recordings still have no duration. Fix it or play the uploaded file, as described above.

## How do I add it to my project?

Run `npx wingo-ui@latest add audio-player`. The CLI copies `audio-player.tsx` into `components/ui` and `use-media-controls.ts` into `hooks`, adds the items the player builds on (Button, Dropdown Menu, Popover, Slider, Spinner, Tooltip and a few helpers), and installs the npm packages they need, such as `motion` and `lucide-react`. The Audio Player, the hook and the Voice Input are Pro items: Pro is $8 a month, $80 a year or $150 once as of October 2026 ([pricing](https://wingo-ui.com/pricing)). More single-component deep dives are in the [component guides category](https://wingo-ui.com/blog/category/components), and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) applies the same keyboard, touch and screen reader rules to every form control.

## Components in this post

- [Audio Player](https://wingo-ui.com/components/audio-player) (Pro): A React audio player with a waveform you can see and scrub, as a podcast card, a chat voice message or a thin call-log bar. Install: `npx wingo-ui@latest add audio-player`
- [Voice Input](https://wingo-ui.com/components/voice-input) (Pro): A React voice input: a mic that grows into a recording pill with a live waveform and transcript, a full-width bar, or a voice orb. Install: `npx wingo-ui@latest add voice-input`
- [useMediaControls](https://wingo-ui.com/components/use-media-controls) (Pro): A React hook for custom audio and video players: play, seek, volume, speed, buffering and a smooth playhead from one media element. Install: `npx wingo-ui@latest add use-media-controls`

## FAQ

### How do I show a waveform for an audio file in React?

Either decode the file in the browser with the Web Audio API and take the loudest sample per bucket, or precompute the peaks on the server with a tool such as BBC audiowaveform and pass them as an array. Browser decoding is fine for short voice notes; precompute for podcasts and long calls.

### Is wavesurfer.js the best way to add a waveform in React?

wavesurfer.js is a mature BSD-3-Clause library with an official React package, @wavesurfer/react, that exposes a WavesurferPlayer component and a useWavesurfer hook. It is a good choice when you want regions, spectrograms or other plugins. If you want a finished player UI with chat and call-log layouts as source code you can edit, a component such as the Wingo UI Audio Player fits better.

### How do I make an audio waveform keyboard accessible?

Give the waveform role slider, a label such as Seek, and aria-valuetext with the position as text, for example 1:12 of 4:05, so a screen reader does not read a raw number of seconds. Arrow keys should move a small step and Page Up and Page Down a larger jump, as in the W3C media seek slider example.

### Why does my recorded WebM voice note have no duration in Chrome?

Chrome's MediaRecorder has long written WebM files without duration metadata (Chromium issue 642012), so the audio element can report Infinity. Chrome 140 adds the duration to recordings made in one piece, but recordings made with a timeslice or requestData() still lack it, so fix the duration in the file or play the stored upload instead of the fresh Blob.

### Is the Wingo UI Audio Player free?

No. The Audio Player, the useMediaControls hook and the Voice Input are Pro items. Some of the parts they build on, such as the Button, Tooltip, Popover, Dropdown Menu and Spinner, are free; the Slider is Pro.

---

Source: https://wingo-ui.com/blog/react-audio-player-with-waveform
