Sports

Play-by-Play

A live play-by-play feed with clock, possession bars, scoring chips, and a running score that updates as plays arrive.

Install

npx shadcn@latest add @paragon/play-by-play

play-by-play.tsx

"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>
  );
}