Sports

Live Score

A live scoreboard whose scores roll vertically on change, with a pulsing live pill and tabular period and clock.

Install

npx shadcn@latest add @paragon/live-score

live-score.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface LiveScoreTeam {
  /** Short team code, e.g. "ARS". */
  code: string;
  name: string;
  score: number;
  /** Optional accent color for the team crest dot. */
  color?: string;
}

export interface LiveScoreProps extends React.ComponentProps<"div"> {
  home: LiveScoreTeam;
  away: LiveScoreTeam;
  /** Period label, e.g. "2nd Half", "Q4", "OT". */
  period?: string;
  /** Game clock, monospace, e.g. "67:24". */
  clock?: string;
  /** Live status pill; false renders "Final". */
  live?: boolean;
  /** Renders score changes instantly, no roll. */
  static?: boolean;
}

/**
 * A scoreboard whose scores roll vertically when they change (direction-aware,
 * always upward for a goal), with the period and clock in tabular figures.
 * The live pill pulses on a CSS keyframe that freezes under reduced motion.
 * Score rolls fall back to an instant swap when reduced motion is requested.
 */
export function LiveScore({
  home,
  away,
  period = "2nd Half",
  clock = "67:24",
  live = true,
  static: isStatic = false,
  className,
  ...props
}: LiveScoreProps) {
  return (
    <div
      data-slot="live-score"
      className={cn(
        "flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between text-xs">
        <span className="font-medium text-muted-foreground">{period}</span>
        {live ? <LivePill /> : (
          <span className="font-medium text-muted-foreground">Final</span>
        )}
      </div>

      <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
        <TeamCell team={home} align="start" />
        <div className="flex items-center gap-2">
          <ScoreRoll value={home.score} isStatic={isStatic} />
          <span className="text-2xl font-light text-muted-foreground/50">
            :
          </span>
          <ScoreRoll value={away.score} isStatic={isStatic} />
        </div>
        <TeamCell team={away} align="end" />
      </div>

      <div className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 font-mono text-xs tabular-nums text-muted-foreground">
          {clock}
        </span>
      </div>
    </div>
  );
}

function TeamCell({
  team,
  align,
}: {
  team: LiveScoreTeam;
  align: "start" | "end";
}) {
  return (
    <div
      className={cn(
        "flex min-w-0 items-center gap-2",
        align === "end" && "flex-row-reverse",
      )}
    >
      <span
        aria-hidden
        className="size-6 shrink-0 rounded-full"
        style={{ background: team.color ?? "var(--muted-foreground)" }}
      />
      <div className={cn("min-w-0", align === "end" && "text-right")}>
        <div className="truncate text-sm font-semibold">{team.code}</div>
        <div className="truncate text-xs text-muted-foreground">
          {team.name}
        </div>
      </div>
    </div>
  );
}

function ScoreRoll({ value, isStatic }: { value: number; isStatic: boolean }) {
  const reducedMotion = useReducedMotion();
  const immediate = isStatic || !!reducedMotion;

  return (
    <span className="relative inline-flex h-8 w-[0.7em] items-center justify-center overflow-hidden text-3xl font-semibold tabular-nums">
      <span className="sr-only">{value}</span>
      {immediate ? (
        <span aria-hidden>{value}</span>
      ) : (
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={value}
            aria-hidden
            initial={{ y: "-100%", opacity: 0 }}
            animate={{ y: "0%", opacity: 1 }}
            exit={{ y: "100%", opacity: 0 }}
            transition={{ type: "spring", duration: 0.4, bounce: 0 }}
            className="absolute inset-0 flex items-center justify-center"
          >
            {value}
          </motion.span>
        </AnimatePresence>
      )}
    </span>
  );
}

function LivePill() {
  return (
    <>
      <style href="paragon-live-score" precedence="paragon">{`
        @keyframes paragon-live-score-pulse {
          0%, 100% { opacity: 1; }
          50% { opacity: 0.4; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-live-dot] { animation: none !important; }
        }
      `}</style>
      <span className="inline-flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 font-medium text-destructive">
        <span
          data-live-dot
          aria-hidden
          className="size-1.5 rounded-full bg-destructive"
          style={{
            animation: "paragon-live-score-pulse 1.6s var(--ease-in-out) infinite",
          }}
        />
        LIVE
      </span>
    </>
  );
}