A live play-by-play feed with clock, possession bars, scoring chips, and a running score that updates as plays arrive.
npx shadcn@latest add @paragon/play-by-play"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface PlayEvent {
id: number;
/** Game clock label, e.g. "07:42". */
clock: string;
/** Team code with possession. */
team: string;
teamColor: string;
/** Play description. */
text: string;
/** Points scored on this play, if any. */
points?: number;
/** Running score after this play, "away-home". */
score: [number, number];
}
export interface PlayByPlayProps extends React.ComponentProps<"div"> {
awayCode?: string;
homeCode?: string;
events?: PlayEvent[];
}
const DEFAULT_EVENTS: PlayEvent[] = [
{ id: 1, clock: "08:14", team: "BOS", teamColor: "#008348", text: "Tatum drives, layup good", points: 2, score: [48, 52] },
{ id: 2, clock: "07:58", team: "LAL", teamColor: "#552583", text: "James pull-up jumper", points: 2, score: [50, 52] },
{ id: 3, clock: "07:42", team: "BOS", teamColor: "#008348", text: "Brown misses three, rebound Davis", score: [50, 52] },
{ id: 4, clock: "07:29", team: "LAL", teamColor: "#552583", text: "Reaves buries the corner three", points: 3, score: [53, 52] },
{ id: 5, clock: "07:11", team: "BOS", teamColor: "#008348", text: "Timeout, Boston", score: [53, 52] },
];
/**
* A live play-by-play feed. Each entry shows the clock, a possession bar in the
* team color, the play text, and a scoring chip when points change; the running
* score sits on the right. New plays prepend and enter with the house language
* (fade + rise + blur), flattened under reduced motion. Scoring plays get a
* subtle tinted background.
*/
export function PlayByPlay({
awayCode = "LAL",
homeCode = "BOS",
events = DEFAULT_EVENTS,
className,
...props
}: PlayByPlayProps) {
const reduced = useReducedMotion();
const ordered = React.useMemo(() => [...events].reverse(), [events]);
return (
<div
data-slot="play-by-play"
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<h3 className="text-sm font-semibold">Play-by-play</h3>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{awayCode} · {homeCode}
</span>
</div>
<ul className="max-h-80 divide-y divide-border/60 overflow-y-auto overscroll-contain">
<AnimatePresence initial={false}>
{ordered.map((ev) => (
<motion.li
key={ev.id}
layout={reduced ? false : "position"}
initial={reduced ? false : { opacity: 0, y: -8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"flex items-center gap-3 px-4 py-2.5",
ev.points && "bg-accent/40",
)}
>
<span className="w-11 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{ev.clock}
</span>
<span
aria-hidden
className="h-8 w-1 shrink-0 rounded-full"
style={{ background: ev.teamColor }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold" style={{ color: ev.teamColor }}>
{ev.team}
</span>
{ev.points ? (
<span className="rounded bg-foreground px-1 py-0.5 text-[10px] font-bold text-background">
+{ev.points}
</span>
) : null}
</div>
<p className="truncate text-sm text-foreground/90">{ev.text}</p>
</div>
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums">
{ev.score[0]}-{ev.score[1]}
</span>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
);
}