# Media Podcast Episode Row — React Media Component

> 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.

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

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/media-podcast-episode-row.json"
```

The file is written to `components/pixelpapa/media-podcast-episode-row.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 { Check, Pause, Play } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";

type Episode = { id: string; number: number; title: string; guest: string; minutes: number; hue: number };

type PodcastEpisodeListProps = {
  episodes: Episode[];
  /** Episode that starts out playing. */
  defaultPlaying?: string;
  /** Starting progress per episode id, 0–1. */
  defaultProgress?: Record<string, number>;
  /** Simulated minutes that pass per second. */
  rate?: number;
  className?: string;
};

const R = 17;
const C = 2 * Math.PI * R;

export function PodcastEpisodeList({ episodes, defaultPlaying, defaultProgress = {}, rate = 0.5, className }: PodcastEpisodeListProps) {
  const uid = useId();
  const [current, setCurrent] = useState<string | null>(defaultPlaying ?? null);
  const [playing, setPlaying] = useState(Boolean(defaultPlaying));
  const [progress, setProgress] = useState<Record<string, number>>(defaultProgress);

  // Simulated playback: no audio, just a clock.
  useEffect(() => {
    if (!playing || !current) return;
    const ep = episodes.find((e) => e.id === current);
    if (!ep) return;
    const t = setInterval(() => {
      setProgress((p) => ({ ...p, [current]: Math.min(1, (p[current] ?? 0) + rate / 4 / ep.minutes) }));
    }, 250);
    return () => clearInterval(t);
  }, [playing, current, episodes, rate]);

  useEffect(() => {
    if (current && (progress[current] ?? 0) >= 1) setPlaying(false);
  }, [progress, current]);

  function toggle(id: string) {
    if (id === current) return setPlaying((p) => !p);
    if ((progress[id] ?? 0) >= 1) setProgress((p) => ({ ...p, [id]: 0 }));
    setCurrent(id);
    setPlaying(true);
  }

  return (
    <ul className={cn("flex flex-col gap-1", className)}>
      <style>{`@keyframes pp-media-podcast-episode-row-eq { 0%, 100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }`}</style>
      {episodes.map((ep) => {
        const active = ep.id === current;
        const live = active && playing;
        const p = progress[ep.id] ?? 0;
        const done = p >= 1;
        const left = Math.max(1, Math.round(ep.minutes * (1 - p)));
        return (
          <li key={ep.id} className="relative">
            {active && <motion.span layoutId={`${uid}-active`} className="absolute inset-0 rounded-2xl bg-accent" transition={{ type: "spring", stiffness: 380, damping: 32 }} />}
            <button
              type="button"
              aria-pressed={live}
              aria-label={`${live ? "Pause" : "Play"} episode ${ep.number}: ${ep.title}`}
              onClick={() => toggle(ep.id)}
              className="group relative flex w-full items-center gap-4 rounded-2xl p-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <span
                className="relative flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-xl font-serif text-2xl text-white"
                style={{ background: `linear-gradient(140deg, oklch(0.74 0.15 ${ep.hue}), oklch(0.4 0.16 ${ep.hue + 50}))` }}
              >
                <span className={cn("transition-opacity", live && "opacity-0")}>{ep.number}</span>
                <span className={cn("absolute inset-0 flex items-center justify-center gap-[3px] bg-black/25 transition-opacity", live ? "opacity-100" : "opacity-0")} aria-hidden>
                  {[0, 1, 2, 3].map((b) => (
                    <span key={b} className="h-5 w-1 origin-bottom rounded-full bg-white motion-reduce:animate-none" style={{ animation: `pp-media-podcast-episode-row-eq ${0.7 + b * 0.17}s ease-in-out ${b * -0.3}s infinite` }} />
                  ))}
                </span>
              </span>
              <span className="min-w-0 flex-1">
                <span className={cn("block truncate text-sm font-medium transition-colors", active ? "text-primary" : "text-card-foreground")}>{ep.title}</span>
                <span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
                  <span className="truncate">with {ep.guest}</span>
                  <span>·</span>
                  {done ? (
                    <span className="flex shrink-0 items-center gap-1 text-emerald-600 dark:text-emerald-400">
                      <Check className="size-3" strokeWidth={3} /> Played
                    </span>
                  ) : (
                    <span className="shrink-0 tabular-nums">{p > 0 ? `${left} min left` : `${ep.minutes} min`}</span>
                  )}
                </span>
              </span>
              <span className={cn("relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors", active ? "bg-primary text-primary-foreground" : "bg-muted text-foreground group-hover:bg-primary group-hover:text-primary-foreground")}>
                <svg viewBox="0 0 40 40" className="absolute -inset-1 size-12 -rotate-90" aria-hidden>
                  <circle cx="20" cy="20" r={R} fill="none" stroke="var(--border)" strokeWidth="2" opacity={p > 0 && !done ? 1 : 0} />
                  <circle cx="20" cy="20" r={R} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinecap="round" strokeDasharray={C} strokeDashoffset={C * (1 - (done ? 0 : p))} style={{ transition: "stroke-dashoffset .25s linear" }} />
                </svg>
                {live ? <Pause className="size-4 fill-current" /> : <Play className="ml-0.5 size-4 fill-current" />}
              </span>
            </button>
          </li>
        );
      })}
    </ul>
  );
}

const episodes: Episode[] = [
  { id: "e48", number: 48, title: "Why every dashboard ends up as a spreadsheet", guest: "Hana Okonkwo", minutes: 42, hue: 262 },
  { id: "e47", number: 47, title: "Pricing pages that survive procurement", guest: "Luca Ferrand", minutes: 36, hue: 30 },
  { id: "e46", number: 46, title: "The quiet art of deleting features", guest: "Sofia Almeida", minutes: 51, hue: 155 },
  { id: "e45", number: 45, title: "Support as a product surface", guest: "Dev Raman", minutes: 29, hue: 320 },
];

export default function MediaPodcastEpisodeRowDemo() {
  return (
    <div className="w-full max-w-lg rounded-[28px] border border-border bg-card p-4 shadow-xl shadow-primary/5">
      <div className="flex items-baseline justify-between px-2.5 pt-1.5 pb-3">
        <h3 className="font-serif text-xl text-card-foreground">Shipping Notes</h3>
        <p className="text-xs text-muted-foreground">Season 4 · 12 episodes</p>
      </div>
      <PodcastEpisodeList episodes={episodes} defaultPlaying="e47" defaultProgress={{ e47: 0.38, e45: 1, e46: 0.12 }} />
    </div>
  );
}
```

## AI prompt

```text
Add the "Media Podcast Episode Row" component from PixelPapa UI to this project.

What it is: 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.
Category: Audio & Media. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/media-podcast-episode-row.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/media-podcast-episode-row.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/media-podcast-episode-row" 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 Audio Waveform](https://pixelpapa.com/components/media/media-audio-waveform.md): 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.
- [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 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.
