Overview

An audio player built around its waveform. Played bars fill in smoothly and the ones under the playhead lift while it plays. Hover shows the time and chapter under the pointer, and dragging anywhere on the waveform scrubs. Chapters show as ticks on the waveform and as a list with a progress bar on the current one, the current chapter's name slides into the header, and there are 10-second skips and a speed toggle. The waveform is a keyboard slider: arrows move 5 s (Shift for 15), Home and End jump, Space plays.


Installation

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/media-timeline.tsx
tsx
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";import { AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type MotionValue } from "motion/react";import { cn } from "@/lib/utils";
export type MediaChapter = { time: number; label: string };export type MediaTimelineProps = {  title: string;  /** Length in seconds. */  duration: number;  chapters?: MediaChapter[];  /** Shown under the title, e.g. the show or artist. */  subtitle?: string;  /** Cover image URL. Falls back to a gradient tile. */  cover?: string;  onTimeChange?: (seconds: number) => void;  className?: string;};
const BARS = 72;const SPEEDS = [1, 1.5, 2, 0.75];
function formatTime(seconds: number) {  const safe = Math.max(0, Math.floor(seconds));  return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, "0")}`;}
function Bar({ index, height, played, hover, playing, reduce }: {  index: number;  height: number;  played: MotionValue<number>;  hover: MotionValue<number>;  playing: boolean;  reduce: boolean;}) {  // How much of this bar is behind the playhead (0–1), and how close it is to it.  const fill = useTransform(played, (p) => Math.min(1, Math.max(0, p * BARS - index)));  const clip = useTransform(fill, (f) => `inset(0 ${(1 - f) * 100}% 0 0)`);  const ghost = useTransform([hover, played], ([h, p]: number[]) => (h >= 0 && index < h * BARS && index >= p * BARS ? 1 : 0));  const lift = useTransform(played, (p) => {    if (!playing || reduce) return 1;    const d = Math.abs(p * BARS - index);    return 1 + Math.max(0, 1 - d / 3) * 0.35;  });
  return (    <motion.span className="relative min-w-[2px] flex-1" style={{ height: `${height * 100}%`, scaleY: lift }}>      <span className="absolute inset-0 rounded-full bg-(--color-rule)" />      <motion.span className="absolute inset-0 rounded-full bg-(--color-neutral)/45" style={{ opacity: ghost }} />      <motion.span className="absolute inset-0 rounded-full bg-(--color-ink)" style={{ clipPath: clip }} />    </motion.span>  );}
export function MediaTimeline({ title, duration, chapters = [], subtitle, cover, onTimeChange, className }: MediaTimelineProps) {  const total = Number.isFinite(duration) ? Math.max(1, duration) : 1;  const reduce = !!useReducedMotion();  const time = useMotionValue(0);  const played = useTransform(time, (t) => t / total);  const hover = useMotionValue(-1);  const playheadLeft = useTransform(played, (p) => `${p * 100}%`);  const uid = useId();  const [seconds, setSeconds] = useState(0);  const [playing, setPlaying] = useState(false);  const [speed, setSpeed] = useState(1);  const [scrubbing, setScrubbing] = useState(false);  const [hoverX, setHoverX] = useState<number | null>(null);  const waveRef = useRef<HTMLDivElement>(null);  const resumeAfterScrub = useRef(false);
  const bars = useMemo(    () => Array.from({ length: BARS }, (_, i) => 0.22 + Math.abs(Math.sin(i * 12.9898) * Math.cos(i * 4.1414) * 0.78)),    [],  );
  // Text only re-renders once a second; the bars read the motion value directly.  useMotionValueEvent(time, "change", (t) => {    const s = Math.floor(t);    if (s !== seconds) {      setSeconds(s);      onTimeChange?.(s);    }  });
  useEffect(() => {    if (!playing || scrubbing) return;    let raf = 0;    let last = performance.now();    const tick = (now: number) => {      const next = time.get() + ((now - last) / 1000) * speed;      last = now;      if (next >= total) {        time.set(total);        setPlaying(false);        return;      }      time.set(next);      raf = requestAnimationFrame(tick);    };    raf = requestAnimationFrame(tick);    return () => cancelAnimationFrame(raf);  }, [playing, scrubbing, speed, time, total]);
  const seek = useCallback((t: number) => time.set(Math.min(total, Math.max(0, t))), [time, total]);
  const toggle = () => {    if (!playing && time.get() >= total) seek(0);    setPlaying((p) => !p);  };
  const ratioAt = (clientX: number) => {    const r = waveRef.current?.getBoundingClientRect();    if (!r) return 0;    return Math.min(1, Math.max(0, (clientX - r.left) / r.width));  };
  const sorted = [...chapters].sort((a, b) => a.time - b.time);  const chapterIndex = sorted.reduce((acc, c, i) => (c.time <= seconds ? i : acc), -1);  const chapter = sorted[chapterIndex];  const hoverTime = hoverX === null ? null : hoverX * total;  const hoverChapter = hoverTime === null ? undefined : [...sorted].reverse().find((c) => c.time <= hoverTime);
  const iconBtn =    "grid size-9 place-items-center rounded-full text-(--color-ink-2) outline-none transition-[background-color,color,transform] duration-150 hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-90 focus-visible:ring-2 focus-visible:ring-(--color-focus)";
  return (    <section      aria-label={`Audio player: ${title}`}      className={cn("w-full rounded-[22px] bg-(--color-paper) p-5 shadow-[inset_0_0_0_1px_var(--color-rule),0_12px_32px_-18px_rgba(0,0,0,0.25)] sm:p-6", className)}    >      {/* Header */}      <div className="flex items-center gap-4">        <motion.div          className="relative size-14 shrink-0 overflow-hidden rounded-[14px] bg-[linear-gradient(135deg,#1f1f23,#4b4b55)]"          animate={{ scale: playing && !reduce ? 1 : 0.94, rotate: playing && !reduce ? 0 : -2 }}          transition={{ type: "spring", bounce: 0.4, duration: 0.5 }}        >          {cover ? (            // eslint-disable-next-line @next/next/no-img-element            <img src={cover} alt="" className="size-full object-cover" />          ) : (            <div className="absolute inset-0 bg-[radial-gradient(80%_80%_at_20%_20%,rgba(255,255,255,0.25),transparent_60%)]" />          )}        </motion.div>        <div className="min-w-0 flex-1">          <div className="truncate text-[15px] font-semibold tracking-tight text-(--color-ink)">{title}</div>          <div className="relative mt-0.5 h-[18px] overflow-hidden text-[12.5px] text-(--color-neutral)">            <AnimatePresence initial={false} mode="popLayout">              <motion.div                key={chapter?.label ?? subtitle ?? "none"}                initial={{ y: 12, opacity: 0, filter: "blur(3px)" }}                animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}                exit={{ y: -12, opacity: 0, filter: "blur(3px)" }}                transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}                className="truncate"              >                {chapter ? `Chapter ${chapterIndex + 1} · ${chapter.label}` : subtitle ?? "Ready"}              </motion.div>            </AnimatePresence>          </div>        </div>      </div>
      {/* Waveform scrubber */}      <div className="relative mt-6">        <AnimatePresence>          {hoverTime !== null && !scrubbing ? (            <motion.div              initial={{ opacity: 0, y: 4, scale: 0.95 }}              animate={{ opacity: 1, y: 0, scale: 1 }}              exit={{ opacity: 0, y: 4, scale: 0.95 }}              transition={{ duration: 0.15 }}              className="pointer-events-none absolute -top-9 z-10 -translate-x-1/2 rounded-full bg-(--color-ink) px-2.5 py-1 text-[11.5px] font-medium whitespace-nowrap text-(--color-paper) tabular-nums shadow-[0_4px_12px_-4px_rgba(0,0,0,0.35)]"              style={{ left: `${(hoverX ?? 0) * 100}%` }}            >              {formatTime(hoverTime)}              {hoverChapter ? <span className="opacity-60"> · {hoverChapter.label}</span> : null}            </motion.div>          ) : null}        </AnimatePresence>
        <div          ref={waveRef}          role="slider"          tabIndex={0}          aria-label={`Seek ${title}`}          aria-valuemin={0}          aria-valuemax={Math.round(total)}          aria-valuenow={seconds}          aria-valuetext={`${formatTime(seconds)} of ${formatTime(total)}${chapter ? `, ${chapter.label}` : ""}`}          onPointerMove={(e) => {            const r = ratioAt(e.clientX);            hover.set(r);            setHoverX(r);            if (scrubbing) seek(r * total);          }}          onPointerLeave={() => {            hover.set(-1);            setHoverX(null);          }}          onPointerDown={(e) => {            if (e.button !== 0) return;            try {              e.currentTarget.setPointerCapture(e.pointerId);            } catch {              /* Pointer may already be gone. */            }            resumeAfterScrub.current = playing;            setScrubbing(true);            seek(ratioAt(e.clientX) * total);          }}          onPointerUp={() => {            if (!scrubbing) return;            setScrubbing(false);            if (resumeAfterScrub.current) setPlaying(true);          }}          onPointerCancel={() => setScrubbing(false)}          onKeyDown={(e) => {            const step = e.shiftKey ? 15 : 5;            if (e.key === "ArrowRight") seek(time.get() + step);            else if (e.key === "ArrowLeft") seek(time.get() - step);            else if (e.key === "Home") seek(0);            else if (e.key === "End") seek(total);            else if (e.key === " " || e.key === "k") toggle();            else return;            e.preventDefault();          }}          className={cn(            "relative flex h-16 touch-none items-center gap-[3px] rounded-[10px] outline-none select-none",            "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)",            scrubbing ? "cursor-grabbing" : "cursor-pointer",          )}        >          {bars.map((h, i) => (            <Bar key={i} index={i} height={h} played={played} hover={hover} playing={playing} reduce={reduce} />          ))}          {/* Chapter ticks */}          {sorted.slice(1).map((c) => (            <span              key={c.time}              aria-hidden              className="pointer-events-none absolute -bottom-2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-(--color-neutral)/60"              style={{ left: `${(c.time / total) * 100}%` }}            />          ))}          {/* Playhead */}          <motion.span            aria-hidden            className="pointer-events-none absolute inset-y-0 w-[2px] -translate-x-1/2 rounded-full bg-(--color-ink)"            style={{ left: playheadLeft }}          >            <motion.span              className="absolute -top-1 left-1/2 size-2.5 -translate-x-1/2 rounded-full bg-(--color-ink) shadow-[0_0_0_3px_var(--color-paper)]"              animate={{ scale: scrubbing ? 1.5 : 1 }}              transition={{ type: "spring", bounce: 0.4, duration: 0.3 }}            />          </motion.span>        </div>
        <div className="mt-3 flex justify-between text-[12px] text-(--color-neutral) tabular-nums" aria-hidden>          <span>{formatTime(seconds)}</span>          <span>-{formatTime(total - seconds)}</span>        </div>      </div>
      {/* Controls */}      <div className="mt-3 flex items-center justify-center gap-2">        <button type="button" aria-label="Back 10 seconds" onClick={() => seek(time.get() - 10)} className={iconBtn}>          <svg viewBox="0 0 24 24" className="size-[18px]" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>            <path d="M4 12a8 8 0 1 0 2.4-5.7" />            <path d="M4 4v4h4" />            <text x="12" y="15.5" textAnchor="middle" fontSize="7" fill="currentColor" stroke="none" fontWeight="600">10</text>          </svg>        </button>        <motion.button          type="button"          onClick={toggle}          aria-label={playing ? "Pause" : seconds >= total ? "Replay" : "Play"}          whileTap={{ scale: 0.92 }}          className="grid size-12 place-items-center rounded-full bg-(--color-ink) text-(--color-paper) shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_6px_16px_-6px_rgba(0,0,0,0.4)] outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)"        >          <AnimatePresence initial={false} mode="popLayout">            <motion.svg              key={playing ? "pause" : "play"}              viewBox="0 0 24 24"              className="size-5"              fill="currentColor"              initial={{ scale: 0.4, opacity: 0, rotate: -30 }}              animate={{ scale: 1, opacity: 1, rotate: 0 }}              exit={{ scale: 0.4, opacity: 0, rotate: 30 }}              transition={{ type: "spring", bounce: 0.4, duration: 0.35 }}              aria-hidden            >              {playing ? (                <>                  <rect x="6" y="5" width="4" height="14" rx="1.2" />                  <rect x="14" y="5" width="4" height="14" rx="1.2" />                </>              ) : (                <path d="M8 5.6v12.8a1 1 0 0 0 1.5.86l10.2-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6z" />              )}            </motion.svg>          </AnimatePresence>        </motion.button>        <button type="button" aria-label="Forward 10 seconds" onClick={() => seek(time.get() + 10)} className={iconBtn}>          <svg viewBox="0 0 24 24" className="size-[18px]" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>            <path d="M20 12a8 8 0 1 1-2.4-5.7" />            <path d="M20 4v4h-4" />            <text x="12" y="15.5" textAnchor="middle" fontSize="7" fill="currentColor" stroke="none" fontWeight="600">10</text>          </svg>        </button>        <button          type="button"          aria-label={`Playback speed ${speed}×`}          onClick={() => setSpeed((s) => SPEEDS[(SPEEDS.indexOf(s) + 1) % SPEEDS.length])}          className="ml-1 h-8 min-w-12 rounded-full px-2.5 text-[12px] font-semibold text-(--color-ink-2) tabular-nums shadow-[inset_0_0_0_1px_var(--color-rule)] outline-none transition-colors hover:bg-(--color-paper-2) focus-visible:ring-2 focus-visible:ring-(--color-focus)"        >          <AnimatePresence initial={false} mode="popLayout">            <motion.span              key={speed}              className="inline-block"              initial={{ y: 8, opacity: 0 }}              animate={{ y: 0, opacity: 1 }}              exit={{ y: -8, opacity: 0 }}              transition={{ duration: 0.18 }}            >              {speed}×            </motion.span>          </AnimatePresence>        </button>      </div>
      {/* Chapters */}      {sorted.length > 0 ? (        <div className="mt-5 border-t border-(--color-rule) pt-3">          <div role="list" aria-label="Chapters" className="flex flex-col">            {sorted.map((c, i) => {              const end = sorted[i + 1]?.time ?? total;              const isActive = i === chapterIndex;              return (                <div role="listitem" key={`${c.time}-${c.label}`}>                  <button                    type="button"                    onClick={() => {                      seek(c.time);                      setPlaying(true);                    }}                    className={cn(                      "relative flex w-full items-center gap-3 overflow-hidden rounded-[10px] px-2.5 py-2 text-left text-[13px] outline-none",                      "transition-colors hover:bg-(--color-paper-2) focus-visible:ring-2 focus-visible:ring-(--color-focus)",                      isActive ? "text-(--color-ink)" : "text-(--color-ink-2)",                    )}                  >                    {isActive ? (                      <motion.span                        layoutId={`${uid}-chapter`}                        className="absolute inset-0 -z-0 rounded-[10px] bg-(--color-paper-2)"                        transition={{ type: "spring", bounce: 0.15, duration: 0.4 }}                      />                    ) : null}                    <span className="relative w-10 shrink-0 text-[12px] text-(--color-neutral) tabular-nums">{formatTime(c.time)}</span>                    <span className={cn("relative min-w-0 flex-1 truncate", isActive && "font-medium")}>{c.label}</span>                    {isActive ? (                      <span className="relative h-1 w-12 shrink-0 overflow-hidden rounded-full bg-(--color-rule)">                        <motion.span                          className="absolute inset-0 origin-left rounded-full bg-(--color-ink)"                          style={{ scaleX: (seconds - c.time) / Math.max(1, end - c.time) }}                        />                      </span>                    ) : null}                  </button>                </div>              );            })}          </div>        </div>      ) : null}    </section>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { MediaTimeline } from "@/components/ui/media-timeline";
<MediaTimeline  title="Designing for the in-between"  subtitle="Craft notes · Episode 12"  duration={312}  chapters={[    { time: 0, label: "Opening" },    { time: 68, label: "Finding the signal" },    { time: 174, label: "Building the idea" },  ]}  onTimeChange={(s) => audio.currentTime = s}/>

Props

PropTypeRequiredDefaultDescription
titlestringYes—Track title.
durationnumberYes—Length in seconds.
chapters{ time: number; label: string }[]No—Chapter starts in seconds. Shown as ticks and as a clickable list.
subtitlestringNo—Line under the title until a chapter starts.
coverstringNo—Cover image URL. Falls back to a gradient tile.
onTimeChange(seconds: number) => voidNo—Fires once per second of playback or seeking, to sync a real media element.
classNamestringNo—Classes for the card.

Best Practices

  1. The player keeps its own clock for the demo; in a real app, drive a media element from onTimeChange and its play state.
  2. Name chapters by what happens in them, so the hover preview is useful.
  3. The waveform is decorative; for real audio, swap the generated bar heights for peaks from your file.
  4. Reduced motion keeps the fill but stops the bars lifting under the playhead.