# Media Audio Waveform — React Media Component

> A voice-note waveform that fills as it plays, previews where you are about to seek on hover and lets you click, drag or arrow-key to any point.

- Category: [Audio & Media](https://pixelpapa.com/components/media.md)
- Tags: media, audio, waveform, seek, voice
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/media/media-audio-waveform

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/media-audio-waveform.json"
```

The file is written to `components/pixelpapa/media-audio-waveform.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Pause, Play } from "lucide-react";
import { motion, useAnimationFrame, useMotionTemplate, useMotionValue, useTransform } from "motion/react";
import { useMemo, useState, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "@/lib/utils";

type AudioWaveformProps = {
  /** Length in seconds. */
  duration: number;
  bars?: number;
  /** Changes the generated shape. */
  seed?: number;
  /** Starting position, 0–1. */
  defaultProgress?: number;
  className?: string;
};

const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const speeds = [1, 1.5, 2];

export function AudioWaveform({ duration, bars = 64, seed = 7, defaultProgress = 0, className }: AudioWaveformProps) {
  const [playing, setPlaying] = useState(false);
  const [seconds, setSeconds] = useState(Math.floor(defaultProgress * duration));
  const [speed, setSpeed] = useState(0);
  const progress = useMotionValue(defaultProgress);
  const hover = useMotionValue(0);
  const played = useTransform(progress, (p) => `${(1 - p) * 100}%`);
  const ghost = useTransform(hover, (p) => `${(1 - p) * 100}%`);
  const playedClip = useMotionTemplate`inset(0 ${played} 0 0)`;
  const ghostClip = useMotionTemplate`inset(0 ${ghost} 0 0)`;

  // Deterministic pseudo-random heights shaped like speech: bursts with quiet gaps.
  const heights = useMemo(
    () =>
      Array.from({ length: bars }, (_, i) => {
        const r = Math.sin(i * 12.9898 + seed * 78.233) * 43758.5453;
        const noise = r - Math.floor(r);
        const phrase = 0.55 + 0.45 * Math.sin(i * 0.42 + seed);
        return Math.max(0.12, noise * phrase);
      }),
    [bars, seed],
  );

  function seek(p: number) {
    const v = Math.min(1, Math.max(0, p));
    progress.set(v);
    setSeconds(Math.floor(v * duration));
  }

  useAnimationFrame((_, delta) => {
    if (!playing) return;
    const p = progress.get() + (Math.min(delta, 50) / 1000 / duration) * speeds[speed];
    if (p >= 1) {
      seek(0);
      setPlaying(false);
    } else {
      progress.set(p);
      const s = Math.floor(p * duration);
      if (s !== seconds) setSeconds(s);
    }
  });

  const ratio = (e: PointerEvent<HTMLDivElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    return (e.clientX - r.left) / r.width;
  };

  function onKeyDown(e: KeyboardEvent) {
    if (e.key === "ArrowRight") seek(progress.get() + 5 / duration);
    else if (e.key === "ArrowLeft") seek(progress.get() - 5 / duration);
    else if (e.key === "Home") seek(0);
    else if (e.key === "End") seek(1);
    else return;
    e.preventDefault();
  }

  const wave = (tone: string) => (
    <div className={cn("flex h-full items-center gap-[3px]", tone)}>
      {heights.map((h, i) => (
        <span key={i} className="min-w-0 flex-1 rounded-full bg-current" style={{ height: `${h * 100}%` }} />
      ))}
    </div>
  );

  return (
    <div className={cn("flex items-center gap-4", className)}>
      <motion.button
        type="button"
        aria-label={playing ? "Pause" : "Play"}
        onClick={() => setPlaying((p) => !p)}
        whileTap={{ scale: 0.9 }}
        className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
      >
        {playing ? <Pause className="size-5 fill-current" /> : <Play className="ml-0.5 size-5 fill-current" />}
      </motion.button>
      <div className="min-w-0 flex-1">
        <div
          role="slider"
          tabIndex={0}
          aria-label="Seek"
          aria-valuemin={0}
          aria-valuemax={duration}
          aria-valuenow={seconds}
          aria-valuetext={`${clock(seconds)} of ${clock(duration)}`}
          onKeyDown={onKeyDown}
          onPointerDown={(e) => {
            e.currentTarget.setPointerCapture(e.pointerId);
            seek(ratio(e));
          }}
          onPointerMove={(e) => {
            hover.set(Math.min(1, Math.max(0, ratio(e))));
            if (e.buttons === 1) seek(ratio(e));
          }}
          onPointerLeave={() => hover.set(0)}
          className="relative h-12 cursor-pointer touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-card"
        >
          {wave("text-muted-foreground/30")}
          <motion.div className="absolute inset-0" style={{ clipPath: ghostClip }}>
            {wave("text-primary/35")}
          </motion.div>
          <motion.div className="absolute inset-0" style={{ clipPath: playedClip }}>
            {wave("text-primary")}
          </motion.div>
        </div>
        <div className="mt-1.5 flex items-center justify-between text-xs text-muted-foreground tabular-nums">
          <span>
            {clock(seconds)} / {clock(duration)}
          </span>
          <button
            type="button"
            aria-label={`Playback speed ${speeds[speed]}×`}
            onClick={() => setSpeed((s) => (s + 1) % speeds.length)}
            className="rounded-full bg-muted px-2 py-0.5 font-medium text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
          >
            {speeds[speed]}×
          </button>
        </div>
      </div>
    </div>
  );
}

export default function MediaAudioWaveformDemo() {
  return (
    <div className="w-full max-w-lg rounded-[28px] border border-border bg-card p-6 shadow-xl shadow-primary/5">
      <div className="mb-5 flex items-center gap-3">
        <span className="flex size-10 items-center justify-center rounded-full bg-linear-to-br from-[oklch(0.78_0.14_20)] to-[oklch(0.54_0.2_340)] text-sm font-semibold text-white">LM</span>
        <div className="text-sm">
          <p className="font-medium text-card-foreground">Lea Moreau</p>
          <p className="text-xs text-muted-foreground">Voice note · Standup recap · 09:14</p>
        </div>
      </div>
      <AudioWaveform duration={94} defaultProgress={0.34} />
    </div>
  );
}
```

## AI prompt

```text
Add the "Media Audio Waveform" component from PixelPapa UI to this project.

What it is: A voice-note waveform that fills as it plays, previews where you are about to seek on hover and lets you click, drag or arrow-key to any point.
Category: Audio & Media. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/media-audio-waveform.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/media-audio-waveform.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/media-audio-waveform" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More audio & media

- [Media Image Compare Slider](https://pixelpapa.com/components/media/media-image-compare-slider.md): A before/after comparison you drag anywhere on the image; the divider follows on a tight spring, works from the keyboard and nudges itself once as a hint.
- [Media Music Player Card](https://pixelpapa.com/components/media/media-music-player-card.md) (Pro): A music player whose record slides out of its sleeve and spins while playing, with a seekable scrubber and track changes that slide in.
- [Media Podcast Episode Row](https://pixelpapa.com/components/media/media-podcast-episode-row.md): An episode list where the playing row gets a sliding highlight, a live equaliser on its cover and a progress ring around the play button.
- [Media Video Player Controls](https://pixelpapa.com/components/media/media-video-player-controls.md) (Pro): A video player shell with a full control bar: scrubber with buffer and hover time, expanding volume, captions, speed and keyboard shortcuts over a drawn scene.
