Media

Episode List

A streaming-series episode list with still thumbnails, synopses, runtimes, watched-progress bars, and state-aware continue actions.

Install

npx shadcn@latest add @paragon/episode-list

episode-list.tsx

"use client";

import * as React from "react";
import { Check, Play, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Episode {
  id: string;
  number: number;
  title: string;
  synopsis: string;
  /** Runtime label, e.g. "52m". */
  runtime: string;
  /** Watched fraction 0–1. 1 = finished, 0 = unwatched. */
  progress?: number;
  colors?: [string, string];
}

export interface EpisodeListProps extends React.ComponentProps<"div"> {
  season?: string;
  episodes?: Episode[];
}

const DEFAULT_EPISODES: Episode[] = [
  {
    id: "1",
    number: 1,
    title: "The Pilot",
    synopsis: "A new analyst arrives as the firm faces its largest audit yet.",
    runtime: "58m",
    progress: 1,
    colors: ["#4f46e5", "#7c3aed"],
  },
  {
    id: "2",
    number: 2,
    title: "Material Weakness",
    synopsis: "A discrepancy in the ledger threatens to unravel the quarter.",
    runtime: "52m",
    progress: 0.62,
    colors: ["#0891b2", "#0ea5e9"],
  },
  {
    id: "3",
    number: 3,
    title: "Going Concern",
    synopsis: "Leadership weighs a restructuring as investors grow uneasy.",
    runtime: "49m",
    progress: 0,
    colors: ["#db2777", "#f43f5e"],
  },
  {
    id: "4",
    number: 4,
    title: "The Restatement",
    synopsis: "Old decisions resurface when the board demands answers.",
    runtime: "55m",
    progress: 0,
    colors: ["#059669", "#10b981"],
  },
];

/**
 * A streaming-series episode list. Each row shows a gradient still, episode
 * number and title, synopsis, runtime, and a progress bar; the primary action
 * adapts to state — Play, Continue (with a "12m left" hint), or Rewatch. Rows
 * are buttons with hover elevation; no looping animation.
 */
export function EpisodeList({
  season = "Season 1",
  episodes = DEFAULT_EPISODES,
  className,
  ...props
}: EpisodeListProps) {
  return (
    <div
      data-slot="episode-list"
      className={cn(
        "w-full max-w-2xl rounded-xl bg-card p-2 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between px-3 py-2">
        <h3 className="text-sm font-semibold">{season}</h3>
        <span className="text-xs tabular-nums text-muted-foreground">
          {episodes.length} episodes
        </span>
      </div>
      <ul className="flex flex-col gap-1">
        {episodes.map((ep) => {
          const finished = (ep.progress ?? 0) >= 1;
          const started = (ep.progress ?? 0) > 0 && !finished;
          const [from, to] = ep.colors ?? ["#475569", "#334155"];
          return (
            <li key={ep.id}>
              <button
                type="button"
                className="group flex w-full items-center gap-3 rounded-lg p-2 text-left transition-colors duration-150 hover:bg-accent focus-visible:bg-accent"
              >
                <span className="relative aspect-video w-28 shrink-0 overflow-hidden rounded-md">
                  <span
                    aria-hidden
                    className="absolute inset-0"
                    style={{
                      background: `linear-gradient(135deg, ${from}, ${to})`,
                    }}
                  />
                  <span
                    aria-hidden
                    className="absolute inset-0 grid place-items-center bg-black/25 opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-visible:opacity-100"
                  >
                    <span className="grid size-8 place-items-center rounded-full bg-black/50 text-white">
                      {finished ? (
                        <RotateCcw className="size-4" />
                      ) : (
                        <Play className="size-4 translate-x-px fill-current" />
                      )}
                    </span>
                  </span>
                  {(ep.progress ?? 0) > 0 && (
                    <span
                      aria-hidden
                      className="absolute inset-x-0 bottom-0 h-1 bg-black/40"
                    >
                      <span
                        className="block h-full bg-destructive"
                        style={{
                          width: `${Math.round((ep.progress ?? 0) * 100)}%`,
                        }}
                      />
                    </span>
                  )}
                </span>

                <div className="min-w-0 flex-1">
                  <div className="flex items-baseline gap-2">
                    <span className="text-sm font-semibold tabular-nums">
                      {ep.number}. {ep.title}
                    </span>
                    <span className="ml-auto shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
                      {ep.runtime}
                    </span>
                  </div>
                  <p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
                    {ep.synopsis}
                  </p>
                  <div className="mt-1.5 flex items-center gap-2">
                    {finished ? (
                      <span className="inline-flex items-center gap-1 text-xs font-medium text-success">
                        <Check className="size-3.5" /> Watched
                      </span>
                    ) : started ? (
                      <span className="text-xs font-medium text-foreground">
                        Continue watching
                      </span>
                    ) : (
                      <span className="text-xs font-medium text-muted-foreground">
                        Not started
                      </span>
                    )}
                  </div>
                </div>
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}