Web InnoventixFreeCode

Progress Drag Slider

Original · free

media scrubber slider

byWeb InnoventixReact + Tailwind
sldprogressdragsliders
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/sld-progress-drag.json
sld-progress-drag.tsx
"use client";

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

type Chapter = { time: number; title: string };

const DURATION = 2732; // 45:32 episode

const CHAPTERS: Chapter[] = [
  { time: 0, title: "Cold open" },
  { time: 214, title: "Who actually pays maintainers" },
  { time: 726, title: "The bus-factor problem" },
  { time: 1320, title: "Funding models that stuck" },
  { time: 1980, title: "Burnout & boundaries" },
  { time: 2450, title: "What we'd fund next" },
];

const RATES = [0.75, 1, 1.25, 1.5, 2] as const;

const WAVE_BARS = 56;

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

function clampTime(t: number): number {
  return Math.min(DURATION, Math.max(0, t));
}

function fractionFrom(el: HTMLElement | null, clientX: number): number {
  if (!el) return 0;
  const r = el.getBoundingClientRect();
  if (r.width === 0) return 0;
  return Math.min(1, Math.max(0, (clientX - r.left) / r.width));
}

export default function SldProgressDrag() {
  const reduce = useReducedMotion();

  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(214);
  const [buffered, setBuffered] = useState(560);
  const [isDragging, setIsDragging] = useState(false);
  const [scrubTime, setScrubTime] = useState<number | null>(null);
  const [hoverFraction, setHoverFraction] = useState<number | null>(null);
  const [volume, setVolume] = useState(0.72);
  const [isMuted, setIsMuted] = useState(false);
  const [volDragging, setVolDragging] = useState(false);
  const [rateIndex, setRateIndex] = useState(1);

  const trackRef = useRef<HTMLDivElement | null>(null);
  const volTrackRef = useRef<HTMLDivElement | null>(null);

  const playbackRate = RATES[rateIndex];

  const bars = useMemo(() => {
    const out: number[] = [];
    for (let i = 0; i < WAVE_BARS; i++) {
      const a = Math.sin(i * 0.37) * 0.5 + 0.5;
      const b = Math.sin(i * 0.13 + 1.7) * 0.5 + 0.5;
      const c = Math.sin(i * 0.91 + 0.4) * 0.5 + 0.5;
      const h = a * 0.52 + b * 0.32 + c * 0.16;
      out.push(Math.min(1, Math.max(0.16, h)));
    }
    return out;
  }, []);

  // Playback simulation
  useEffect(() => {
    if (!isPlaying || isDragging) return;
    const id = window.setInterval(() => {
      setCurrentTime((t) => Math.min(DURATION, t + 0.25 * playbackRate));
    }, 250);
    return () => window.clearInterval(id);
  }, [isPlaying, isDragging, playbackRate]);

  // Stop at end of track
  useEffect(() => {
    if (currentTime >= DURATION && isPlaying) setIsPlaying(false);
  }, [currentTime, isPlaying]);

  // Progressive buffering ahead of the playhead
  useEffect(() => {
    const id = window.setInterval(() => {
      setBuffered((b) => Math.min(DURATION, b + 13));
    }, 650);
    return () => window.clearInterval(id);
  }, []);

  const displayTime =
    isDragging && scrubTime !== null ? scrubTime : currentTime;
  const progressFraction = displayTime / DURATION;
  const shownBuffered = Math.max(buffered, displayTime);
  const bufferedFraction = Math.min(1, shownBuffered / DURATION);
  const effectiveVolume = isMuted ? 0 : volume;

  const currentChapter = useMemo(() => {
    let idx = 0;
    for (let i = 0; i < CHAPTERS.length; i++) {
      if (displayTime >= CHAPTERS[i].time) idx = i;
    }
    return idx;
  }, [displayTime]);

  const togglePlay = useCallback(() => {
    setIsPlaying((p) => {
      if (!p && currentTime >= DURATION) setCurrentTime(0);
      return !p;
    });
  }, [currentTime]);

  // ---- Seek slider pointer + keyboard ----
  const seekPointerDown = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      e.currentTarget.setPointerCapture(e.pointerId);
      setIsDragging(true);
      setScrubTime(fractionFrom(trackRef.current, e.clientX) * DURATION);
    },
    [],
  );

  const seekPointerMove = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      const frac = fractionFrom(trackRef.current, e.clientX);
      setHoverFraction(frac);
      if (isDragging) setScrubTime(frac * DURATION);
    },
    [isDragging],
  );

  const seekPointerUp = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      if (isDragging) {
        setCurrentTime(fractionFrom(trackRef.current, e.clientX) * DURATION);
        setScrubTime(null);
        setIsDragging(false);
      }
      if (e.currentTarget.hasPointerCapture(e.pointerId)) {
        e.currentTarget.releasePointerCapture(e.pointerId);
      }
    },
    [isDragging],
  );

  const seekKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      let handled = true;
      switch (e.key) {
        case "ArrowRight":
        case "ArrowUp":
          setCurrentTime((t) => clampTime(t + 5));
          break;
        case "ArrowLeft":
        case "ArrowDown":
          setCurrentTime((t) => clampTime(t - 5));
          break;
        case "PageUp":
          setCurrentTime((t) => clampTime(t + 30));
          break;
        case "PageDown":
          setCurrentTime((t) => clampTime(t - 30));
          break;
        case "Home":
          setCurrentTime(0);
          break;
        case "End":
          setCurrentTime(DURATION);
          break;
        case " ":
        case "Enter":
          togglePlay();
          break;
        default:
          handled = false;
      }
      if (handled) e.preventDefault();
    },
    [togglePlay],
  );

  // ---- Volume slider pointer + keyboard ----
  const volPointerDown = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      e.currentTarget.setPointerCapture(e.pointerId);
      setVolDragging(true);
      setIsMuted(false);
      setVolume(fractionFrom(volTrackRef.current, e.clientX));
    },
    [],
  );

  const volPointerMove = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      if (!volDragging) return;
      setVolume(fractionFrom(volTrackRef.current, e.clientX));
    },
    [volDragging],
  );

  const volPointerUp = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      setVolDragging(false);
      if (e.currentTarget.hasPointerCapture(e.pointerId)) {
        e.currentTarget.releasePointerCapture(e.pointerId);
      }
    },
    [],
  );

  const volKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
    let handled = true;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        setIsMuted(false);
        setVolume((v) => Math.min(1, v + 0.05));
        break;
      case "ArrowLeft":
      case "ArrowDown":
        setVolume((v) => Math.max(0, v - 0.05));
        break;
      case "Home":
        setVolume(0);
        break;
      case "End":
        setIsMuted(false);
        setVolume(1);
        break;
      default:
        handled = false;
    }
    if (handled) e.preventDefault();
  }, []);

  const knobAnimated = isPlaying && !isDragging && !reduce;

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-4 py-16 sm:py-24 dark:from-zinc-950 dark:to-zinc-900">
      <style>{`
        @keyframes sldpd-eq {
          0%, 100% { transform: scaleY(0.32); }
          50% { transform: scaleY(1); }
        }
        @keyframes sldpd-pulse {
          0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.45); }
          50% { box-shadow: 0 0 0 6px rgba(99, 102, 241, 0); }
        }
        @keyframes sldpd-shimmer {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(320%); }
        }
        .sldpd-eq-bar { transform-origin: bottom; animation: sldpd-eq 0.9s ease-in-out infinite; }
        .sldpd-knob-live { animation: sldpd-pulse 2s ease-in-out infinite; }
        .sldpd-shimmer { animation: sldpd-shimmer 2.4s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sldpd-eq-bar, .sldpd-knob-live, .sldpd-shimmer { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-2xl">
        <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40">
          <div className="p-6 sm:p-8">
            {/* Header */}
            <div className="flex items-start justify-between gap-4">
              <div className="flex items-center gap-2.5">
                <span
                  className="flex h-3.5 items-end gap-[3px]"
                  aria-hidden="true"
                >
                  {[0, 1, 2, 3].map((i) => (
                    <span
                      key={i}
                      className={`w-[3px] rounded-full bg-emerald-500 dark:bg-emerald-400 ${
                        isPlaying && !reduce ? "sldpd-eq-bar" : ""
                      }`}
                      style={{
                        height: "100%",
                        animationDelay: `${i * 0.16}s`,
                        transform: isPlaying && !reduce ? undefined : "scaleY(0.5)",
                        transformOrigin: "bottom",
                      }}
                    />
                  ))}
                </span>
                <span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-zinc-400">
                  {isPlaying ? "Now playing" : "Paused"}
                </span>
              </div>
              <span className="rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-semibold tabular-nums text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30">
                EP 142
              </span>
            </div>

            {/* Title block */}
            <div className="mt-5 flex items-center gap-4">
              <div className="relative hidden h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 shadow-lg shadow-indigo-500/20 sm:flex">
                <svg
                  viewBox="0 0 40 40"
                  className="h-8 w-8 text-white/90"
                  aria-hidden="true"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2}
                  strokeLinecap="round"
                >
                  <path d="M6 20h2M12 12v16M18 6v28M24 10v20M30 14v12M36 20h-2" />
                </svg>
              </div>
              <div className="min-w-0">
                <h2 className="truncate text-lg font-bold text-slate-900 sm:text-xl dark:text-zinc-50">
                  The quiet economics of open-source maintenance
                </h2>
                <p className="mt-0.5 truncate text-sm text-slate-500 dark:text-zinc-400">
                  Signal &amp; Noise · Dana Okafor &amp; Ravi Menon
                </p>
              </div>
            </div>

            {/* Current chapter */}
            <p className="mt-5 text-sm font-medium text-slate-600 dark:text-zinc-300">
              <span className="tabular-nums text-indigo-600 dark:text-indigo-400">
                Ch. {currentChapter + 1}
              </span>
              <span className="mx-2 text-slate-300 dark:text-zinc-600">/</span>
              {CHAPTERS[currentChapter].title}
            </p>

            {/* Scrubber */}
            <div className="mt-3">
              <div
                ref={trackRef}
                role="slider"
                tabIndex={0}
                aria-label="Seek through episode"
                aria-orientation="horizontal"
                aria-valuemin={0}
                aria-valuemax={DURATION}
                aria-valuenow={Math.round(displayTime)}
                aria-valuetext={`${formatTime(displayTime)} of ${formatTime(
                  DURATION,
                )}, chapter ${currentChapter + 1}: ${CHAPTERS[currentChapter].title}`}
                onPointerDown={seekPointerDown}
                onPointerMove={seekPointerMove}
                onPointerUp={seekPointerUp}
                onPointerCancel={seekPointerUp}
                onPointerLeave={() => setHoverFraction(null)}
                onKeyDown={seekKeyDown}
                className="group relative flex h-16 cursor-pointer touch-none select-none items-center rounded-xl px-1 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
              >
                {/* Waveform bars */}
                <div className="pointer-events-none flex h-full w-full items-center gap-[3px]">
                  {bars.map((h, i) => {
                    const centerFrac = (i + 0.5) / WAVE_BARS;
                    const played = centerFrac <= progressFraction;
                    const inBuffer = centerFrac <= bufferedFraction;
                    const color = played
                      ? "bg-indigo-500 dark:bg-indigo-400"
                      : inBuffer
                        ? "bg-slate-300 dark:bg-zinc-600"
                        : "bg-slate-200 dark:bg-zinc-700";
                    return (
                      <span
                        key={i}
                        className={`flex-1 rounded-full transition-colors duration-150 ${color}`}
                        style={{ height: `${Math.round(h * 100)}%` }}
                      />
                    );
                  })}
                </div>

                {/* Chapter tick marks */}
                {CHAPTERS.map((c) =>
                  c.time === 0 ? null : (
                    <span
                      key={c.time}
                      aria-hidden="true"
                      className="pointer-events-none absolute inset-y-3 w-px -translate-x-1/2 bg-slate-900/15 dark:bg-white/20"
                      style={{ left: `${(c.time / DURATION) * 100}%` }}
                    />
                  ),
                )}

                {/* Hover preview */}
                {hoverFraction !== null && !isDragging && (
                  <div
                    aria-hidden="true"
                    className="pointer-events-none absolute inset-y-2"
                    style={{ left: `${hoverFraction * 100}%` }}
                  >
                    <span className="absolute inset-y-0 w-px -translate-x-1/2 bg-slate-400/70 dark:bg-zinc-400/60" />
                    <span className="absolute bottom-full mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold tabular-nums text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900">
                      {formatTime(hoverFraction * DURATION)}
                    </span>
                  </div>
                )}

                {/* Playhead + knob */}
                <div
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-y-1"
                  style={{ left: `${progressFraction * 100}%` }}
                >
                  <span className="absolute inset-y-0 w-px -translate-x-1/2 bg-indigo-500/70 dark:bg-indigo-300/70" />
                  <span
                    className={`absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-indigo-500 bg-white shadow-md transition-transform duration-150 dark:border-indigo-300 dark:bg-zinc-50 ${
                      isDragging ? "scale-125" : "scale-100"
                    } ${knobAnimated ? "sldpd-knob-live" : ""}`}
                  />
                </div>
              </div>

              {/* Time row */}
              <div className="mt-2 flex items-center justify-between text-xs font-medium tabular-nums text-slate-500 dark:text-zinc-400">
                <span>{formatTime(displayTime)}</span>
                <span className="flex items-center gap-1.5">
                  {(isDragging || hoverFraction !== null) && (
                    <span className="hidden rounded bg-slate-100 px-1.5 py-0.5 text-slate-600 sm:inline dark:bg-zinc-800 dark:text-zinc-300">
                      drag to seek
                    </span>
                  )}
                  <span>-{formatTime(DURATION - displayTime)}</span>
                </span>
              </div>
            </div>

            {/* Transport controls */}
            <div className="mt-6 flex items-center justify-between gap-3">
              {/* Playback speed */}
              <button
                type="button"
                onClick={() => setRateIndex((i) => (i + 1) % RATES.length)}
                aria-label={`Playback speed, currently ${playbackRate} times. Tap to change.`}
                className="inline-flex h-9 min-w-[3rem] items-center justify-center rounded-full px-3 text-sm font-bold tabular-nums text-slate-600 ring-1 ring-inset ring-slate-200 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-offset-zinc-900"
              >
                {playbackRate}
                <span className="ml-0.5 text-xs">×</span>
              </button>

              {/* Center transport */}
              <div className="flex items-center gap-2 sm:gap-4">
                <button
                  type="button"
                  onClick={() => setCurrentTime((t) => clampTime(t - 15))}
                  aria-label="Rewind 15 seconds"
                  className="inline-flex flex-col items-center justify-center rounded-full p-2 text-slate-600 transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                >
                  <svg
                    viewBox="0 0 24 24"
                    className="h-5 w-5"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M11 7l-5 5 5 5M18 7l-5 5 5 5" />
                  </svg>
                  <span className="mt-0.5 text-[10px] font-bold leading-none tabular-nums">
                    15
                  </span>
                </button>

                <motion.button
                  type="button"
                  onClick={togglePlay}
                  whileTap={reduce ? undefined : { scale: 0.9 }}
                  aria-label={isPlaying ? "Pause" : "Play"}
                  aria-pressed={isPlaying}
                  className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg shadow-indigo-600/30 transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
                >
                  {isPlaying ? (
                    <svg
                      viewBox="0 0 24 24"
                      className="h-6 w-6"
                      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="h-6 w-6 translate-x-0.5"
                      fill="currentColor"
                      aria-hidden="true"
                    >
                      <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.28-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14z" />
                    </svg>
                  )}
                </motion.button>

                <button
                  type="button"
                  onClick={() => setCurrentTime((t) => clampTime(t + 30))}
                  aria-label="Skip forward 30 seconds"
                  className="inline-flex flex-col items-center justify-center rounded-full p-2 text-slate-600 transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                >
                  <svg
                    viewBox="0 0 24 24"
                    className="h-5 w-5"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M13 7l5 5-5 5M6 7l5 5-5 5" />
                  </svg>
                  <span className="mt-0.5 text-[10px] font-bold leading-none tabular-nums">
                    30
                  </span>
                </button>
              </div>

              {/* Volume */}
              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => setIsMuted((m) => !m)}
                  aria-label={isMuted ? "Unmute" : "Mute"}
                  aria-pressed={isMuted}
                  className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-offset-zinc-900"
                >
                  <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="M4 9v6h3.5L13 19V5L7.5 9H4z"
                      fill="currentColor"
                      stroke="none"
                    />
                    {isMuted || effectiveVolume === 0 ? (
                      <path d="M17 9.5l4 5M21 9.5l-4 5" />
                    ) : (
                      <>
                        <path d="M16.5 8.8a4.5 4.5 0 0 1 0 6.4" />
                        {effectiveVolume > 0.5 && (
                          <path d="M19 6.5a8 8 0 0 1 0 11" />
                        )}
                      </>
                    )}
                  </svg>
                </button>

                <div
                  ref={volTrackRef}
                  role="slider"
                  tabIndex={0}
                  aria-label="Volume"
                  aria-orientation="horizontal"
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuenow={Math.round(effectiveVolume * 100)}
                  aria-valuetext={`Volume ${Math.round(effectiveVolume * 100)} percent`}
                  onPointerDown={volPointerDown}
                  onPointerMove={volPointerMove}
                  onPointerUp={volPointerUp}
                  onPointerCancel={volPointerUp}
                  onKeyDown={volKeyDown}
                  className="group relative hidden h-9 w-20 cursor-pointer touch-none select-none items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:flex dark:focus-visible:ring-offset-zinc-900"
                >
                  <span className="pointer-events-none relative h-1.5 w-full rounded-full bg-slate-200 dark:bg-zinc-700">
                    <span
                      className="absolute inset-y-0 left-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
                      style={{ width: `${effectiveVolume * 100}%` }}
                    />
                    <span
                      className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-indigo-500 bg-white shadow transition-transform duration-150 group-hover:scale-110 dark:border-indigo-300 dark:bg-zinc-50"
                      style={{ left: `${effectiveVolume * 100}%` }}
                    />
                  </span>
                </div>
              </div>
            </div>

            {/* Chapter jump list */}
            <div className="mt-6 border-t border-slate-100 pt-5 dark:border-zinc-800">
              <p className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-zinc-500">
                Chapters
              </p>
              <div className="flex flex-wrap gap-2">
                {CHAPTERS.map((c, i) => {
                  const active = i === currentChapter;
                  return (
                    <button
                      key={c.time}
                      type="button"
                      onClick={() => setCurrentTime(c.time)}
                      aria-current={active ? "true" : undefined}
                      className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
                        active
                          ? "bg-indigo-600 text-white shadow-sm"
                          : "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700 dark:hover:text-zinc-50"
                      }`}
                    >
                      <span
                        className={`tabular-nums ${
                          active
                            ? "text-indigo-100"
                            : "text-slate-400 dark:text-zinc-500"
                        }`}
                      >
                        {formatTime(c.time)}
                      </span>
                      {c.title}
                    </button>
                  );
                })}
              </div>
            </div>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-400 dark:text-zinc-600">
          Drag the waveform, use arrow keys to seek, or jump by chapter.
        </p>

        <span className="sr-only" aria-live="polite">
          {isPlaying ? "Playing" : "Paused"} at {formatTime(displayTime)}
        </span>
      </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 →