Web InnoventixFreeCode

Media Waveform

Original · free

audio waveform scrubber

byWeb InnoventixReact + Tailwind
mediawaveform
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/media-waveform.json
media-waveform.tsx
"use client";

import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Marker = {
  at: number;
  label: string;
};

const DURATION = 214;

const MARKERS: Marker[] = [
  { at: 0, label: "Cold open" },
  { at: 38, label: "Guest intro" },
  { at: 96, label: "The pricing mistake" },
  { at: 158, label: "Listener question" },
  { at: 194, label: "Outro" },
];

const BAR_COUNT = 96;

function buildPeaks(count: number): number[] {
  const peaks: number[] = [];
  for (let i = 0; i < count; i += 1) {
    const t = i / (count - 1);
    const speech = Math.sin(t * 41.3) * 0.5 + Math.sin(t * 17.7 + 1.4) * 0.32;
    const breath = Math.sin(t * 6.1 + 0.6) * 0.22;
    const envelope = 0.42 + 0.58 * Math.sin(Math.PI * Math.min(1, t * 1.06));
    const gap = t > 0.34 && t < 0.365 ? 0.12 : 1;
    const raw = Math.abs(speech + breath) * envelope * gap;
    peaks.push(Math.max(0.08, Math.min(1, raw * 1.15 + 0.1)));
  }
  return peaks;
}

function formatTime(seconds: number): string {
  const safe = Math.max(0, Math.floor(seconds));
  const m = Math.floor(safe / 60);
  const s = safe % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
}

export default function MediaWaveform() {
  const reduceMotion = useReducedMotion();
  const headingId = useId();
  const statusId = useId();

  const peaks = useMemo(() => buildPeaks(BAR_COUNT), []);

  const [time, setTime] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [scrubbing, setScrubbing] = useState(false);
  const [hoverRatio, setHoverRatio] = useState<number | null>(null);
  const [rate, setRate] = useState(1);
  const [muted, setMuted] = useState(false);

  const trackRef = useRef<HTMLDivElement | null>(null);
  const rafRef = useRef<number | null>(null);
  const lastTickRef = useRef<number>(0);
  const rateRef = useRef(rate);
  rateRef.current = rate;

  useEffect(() => {
    if (!playing) {
      lastTickRef.current = 0;
      return;
    }
    const step = (now: number) => {
      if (lastTickRef.current === 0) lastTickRef.current = now;
      const delta = (now - lastTickRef.current) / 1000;
      lastTickRef.current = now;
      setTime((prev) => {
        const next = prev + delta * rateRef.current;
        if (next >= DURATION) {
          setPlaying(false);
          return DURATION;
        }
        return next;
      });
      rafRef.current = requestAnimationFrame(step);
    };
    rafRef.current = requestAnimationFrame(step);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    };
  }, [playing]);

  const ratioFromClientX = useCallback((clientX: number): number => {
    const el = trackRef.current;
    if (!el) return 0;
    const rect = el.getBoundingClientRect();
    if (rect.width === 0) return 0;
    return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
  }, []);

  const seekTo = useCallback((seconds: number) => {
    setTime(Math.min(DURATION, Math.max(0, seconds)));
  }, []);

  useEffect(() => {
    if (!scrubbing) return;
    const onMove = (e: PointerEvent) => {
      seekTo(ratioFromClientX(e.clientX) * DURATION);
    };
    const onUp = () => setScrubbing(false);
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
    };
  }, [scrubbing, ratioFromClientX, seekTo]);

  const onKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      let handled = true;
      switch (e.key) {
        case "ArrowRight":
          seekTo(time + (e.shiftKey ? 15 : 5));
          break;
        case "ArrowLeft":
          seekTo(time - (e.shiftKey ? 15 : 5));
          break;
        case "ArrowUp":
          seekTo(time + 5);
          break;
        case "ArrowDown":
          seekTo(time - 5);
          break;
        case "Home":
          seekTo(0);
          break;
        case "End":
          seekTo(DURATION);
          break;
        case "PageUp":
          seekTo(time + 30);
          break;
        case "PageDown":
          seekTo(time - 30);
          break;
        case " ":
        case "Enter":
          setPlaying((p) => !p);
          break;
        default:
          handled = false;
      }
      if (handled) e.preventDefault();
    },
    [time, seekTo],
  );

  const progress = time / DURATION;
  const activeMarker = useMemo(() => {
    let current = MARKERS[0];
    for (const m of MARKERS) if (time >= m.at) current = m;
    return current;
  }, [time]);

  const hoverSeconds = hoverRatio === null ? null : hoverRatio * DURATION;

  return (
    <section
      aria-labelledby={headingId}
      className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
    >
      <style>{`
        @keyframes mwfm-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(1.9); }
        }
        @keyframes mwfm-drift {
          0% { background-position: 0% 50%; }
          100% { background-position: 200% 50%; }
        }
        .mwfm-live-dot { animation: mwfm-pulse 1.8s ease-in-out infinite; }
        .mwfm-sheen {
          background-image: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.55) 50%, transparent 80%);
          background-size: 200% 100%;
          animation: mwfm-drift 3.4s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .mwfm-live-dot, .mwfm-sheen { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <header className="mb-8 flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
              <span
                aria-hidden="true"
                className={`inline-block h-1.5 w-1.5 rounded-full bg-indigo-500 ${
                  playing && !reduceMotion ? "mwfm-live-dot" : ""
                }`}
              />
              Episode 41 &middot; Margins
            </p>
            <h2 id={headingId} className="text-2xl font-semibold tracking-tight sm:text-3xl">
              Why your agency retainer keeps shrinking
            </h2>
            <p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
              Recorded 12 March &middot; 3 min 34 sec &middot; with Priya Raman
            </p>
          </div>
          <div className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-right dark:border-slate-800 dark:bg-slate-900">
            <div className="font-mono text-lg tabular-nums text-slate-900 dark:text-slate-100">
              {formatTime(time)}
            </div>
            <div className="font-mono text-xs tabular-nums text-slate-500 dark:text-slate-500">
              &minus;{formatTime(DURATION - time)}
            </div>
          </div>
        </header>

        <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900">
          <div
            ref={trackRef}
            role="slider"
            tabIndex={0}
            aria-label="Audio position"
            aria-valuemin={0}
            aria-valuemax={DURATION}
            aria-valuenow={Math.round(time)}
            aria-valuetext={`${formatTime(time)} of ${formatTime(DURATION)}, ${activeMarker.label}`}
            aria-describedby={statusId}
            onKeyDown={onKeyDown}
            onPointerDown={(e) => {
              e.currentTarget.focus();
              setScrubbing(true);
              seekTo(ratioFromClientX(e.clientX) * DURATION);
            }}
            onPointerMove={(e) => setHoverRatio(ratioFromClientX(e.clientX))}
            onPointerLeave={() => setHoverRatio(null)}
            className={`group relative h-28 w-full cursor-pointer touch-none select-none rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:h-32 dark:focus-visible:ring-offset-slate-900 ${
              scrubbing ? "cursor-grabbing" : ""
            }`}
          >
            <div className="flex h-full w-full items-center gap-[2px]" aria-hidden="true">
              {peaks.map((peak, i) => {
                const barRatio = (i + 0.5) / BAR_COUNT;
                const played = barRatio <= progress;
                const nearHover =
                  hoverRatio !== null && Math.abs(barRatio - hoverRatio) < 0.02 && !scrubbing;
                return (
                  <motion.span
                    key={i}
                    className={`flex-1 rounded-full ${
                      played
                        ? "bg-indigo-500 dark:bg-indigo-400"
                        : "bg-slate-300 dark:bg-slate-700"
                    }`}
                    style={{ height: `${peak * 100}%`, transformOrigin: "center" }}
                    animate={
                      reduceMotion
                        ? undefined
                        : { scaleY: nearHover ? 1.14 : 1, opacity: played ? 1 : 0.85 }
                    }
                    transition={{ type: "spring", stiffness: 420, damping: 30 }}
                  />
                );
              })}
            </div>

            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-y-0 w-px bg-slate-900 dark:bg-white"
              style={{ left: `${progress * 100}%` }}
            >
              <span className="absolute -top-1 left-1/2 h-2.5 w-2.5 -translate-x-1/2 rounded-full bg-slate-900 ring-2 ring-white dark:bg-white dark:ring-slate-900" />
              <span className="absolute -bottom-1 left-1/2 h-2.5 w-2.5 -translate-x-1/2 rounded-full bg-slate-900 ring-2 ring-white dark:bg-white dark:ring-slate-900" />
            </div>

            {hoverSeconds !== null ? (
              <div
                aria-hidden="true"
                className="pointer-events-none absolute -top-8 z-10 -translate-x-1/2 rounded-md bg-slate-900 px-2 py-1 font-mono text-[11px] tabular-nums text-white shadow-md dark:bg-white dark:text-slate-900"
                style={{ left: `${(hoverRatio ?? 0) * 100}%` }}
              >
                {formatTime(hoverSeconds)}
              </div>
            ) : null}
          </div>

          <div className="relative mt-3 h-5" aria-hidden="true">
            {MARKERS.map((m) => (
              <span
                key={m.at}
                className="absolute top-0 -translate-x-1/2 text-[10px] font-medium text-slate-400 dark:text-slate-600"
                style={{ left: `${Math.min(97, Math.max(3, (m.at / DURATION) * 100))}%` }}
              >
                {formatTime(m.at)}
              </span>
            ))}
          </div>

          <div className="mt-4 flex flex-wrap items-center justify-between gap-4 border-t border-slate-200 pt-4 dark:border-slate-800">
            <div className="flex items-center gap-1.5">
              <button
                type="button"
                onClick={() => seekTo(time - 15)}
                aria-label="Rewind 15 seconds"
                className="rounded-lg p-2 text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M11 5 4 12l7 7" />
                  <path d="M20 5l-7 7 7 7" />
                </svg>
              </button>

              <button
                type="button"
                onClick={() => setPlaying((p) => !p)}
                aria-label={playing ? "Pause" : "Play"}
                aria-pressed={playing}
                className="relative overflow-hidden rounded-full bg-slate-900 p-3 text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
              >
                {playing && !reduceMotion ? (
                  <span aria-hidden="true" className="mwfm-sheen pointer-events-none absolute inset-0 opacity-30" />
                ) : null}
                {playing ? (
                  <svg viewBox="0 0 24 24" className="relative h-5 w-5" fill="currentColor" aria-hidden="true">
                    <rect x="6" y="5" width="4" height="14" rx="1" />
                    <rect x="14" y="5" width="4" height="14" rx="1" />
                  </svg>
                ) : (
                  <svg viewBox="0 0 24 24" className="relative h-5 w-5" fill="currentColor" aria-hidden="true">
                    <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.3-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
                  </svg>
                )}
              </button>

              <button
                type="button"
                onClick={() => seekTo(time + 15)}
                aria-label="Forward 15 seconds"
                className="rounded-lg p-2 text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M13 5l7 7-7 7" />
                  <path d="M4 5l7 7-7 7" />
                </svg>
              </button>
            </div>

            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => setRate((r) => (r === 1 ? 1.25 : r === 1.25 ? 1.5 : r === 1.5 ? 2 : 1))}
                aria-label={`Playback speed, currently ${rate} times. Activate to change.`}
                className="rounded-lg border border-slate-200 px-2.5 py-1.5 font-mono text-xs tabular-nums text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
              >
                {rate}&times;
              </button>
              <button
                type="button"
                onClick={() => setMuted((m) => !m)}
                aria-label={muted ? "Unmute" : "Mute"}
                aria-pressed={muted}
                className="rounded-lg p-2 text-slate-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800"
              >
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M11 5 6 9H3v6h3l5 4V5Z" />
                  {muted ? (
                    <>
                      <path d="m22 9-6 6" />
                      <path d="m16 9 6 6" />
                    </>
                  ) : (
                    <>
                      <path d="M15.5 8.5a5 5 0 0 1 0 7" />
                      <path d="M18.5 5.5a9 9 0 0 1 0 13" />
                    </>
                  )}
                </svg>
              </button>
            </div>
          </div>
        </div>

        <div className="mt-6">
          <h3 className="mb-3 text-xs font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-500">
            Chapters
          </h3>
          <ul className="grid gap-1.5 sm:grid-cols-2">
            {MARKERS.map((m) => {
              const isActive = activeMarker.at === m.at;
              return (
                <li key={m.at}>
                  <button
                    type="button"
                    onClick={() => seekTo(m.at)}
                    aria-current={isActive ? "true" : undefined}
                    className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                      isActive
                        ? "border-indigo-300 bg-indigo-50 text-indigo-900 dark:border-indigo-500/40 dark:bg-indigo-500/10 dark:text-indigo-200"
                        : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
                    }`}
                  >
                    <span className="truncate">{m.label}</span>
                    <span className="shrink-0 font-mono text-xs tabular-nums opacity-60">
                      {formatTime(m.at)}
                    </span>
                  </button>
                </li>
              );
            })}
          </ul>
        </div>

        <p id={statusId} aria-live="polite" className="sr-only">
          {playing ? "Playing" : "Paused"} at {formatTime(time)}, chapter {activeMarker.label}, speed{" "}
          {rate} times, {muted ? "muted" : "sound on"}.
        </p>

        <p className="mt-6 text-xs text-slate-500 dark:text-slate-500">
          Drag the waveform to scrub. With it focused: arrows seek 5s (hold Shift for 15s), Page
          Up/Down jump 30s, Home and End go to the ends, Space toggles playback.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →