A game box-score table with teams as rows, scoring periods as columns, and an emphasized totals column.
npx shadcn@latest add @paragon/box-score"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface BoxScoreTeam {
code: string;
name: string;
/** Points per period, in order. */
periods: number[];
color?: string;
}
export interface BoxScoreProps extends React.ComponentProps<"div"> {
/** Column labels for each period, e.g. ["Q1","Q2","Q3","Q4"]. */
periodLabels?: string[];
home?: BoxScoreTeam;
away?: BoxScoreTeam;
/** Marks the game final; otherwise the leading total is emphasized. */
final?: boolean;
}
const DEFAULT_HOME: BoxScoreTeam = {
code: "BOS",
name: "Celtics",
periods: [28, 24, 31, 26],
color: "#008348",
};
const DEFAULT_AWAY: BoxScoreTeam = {
code: "LAL",
name: "Lakers",
periods: [22, 30, 27, 25],
color: "#552583",
};
/**
* A game box-score table: teams as rows, scoring periods as columns, with a
* bold totals column. Numbers are tabular; the winning total is emphasized.
* Pure table semantics with a horizontal scroll guard for narrow viewports;
* no motion.
*/
export function BoxScore({
periodLabels = ["Q1", "Q2", "Q3", "Q4"],
home = DEFAULT_HOME,
away = DEFAULT_AWAY,
final = true,
className,
...props
}: BoxScoreProps) {
const rows = [away, home];
const totals = rows.map((t) => t.periods.reduce((a, b) => a + b, 0));
const winner = totals[0] === totals[1] ? -1 : totals[0] > totals[1] ? 0 : 1;
return (
<div
data-slot="box-score"
className={cn(
"w-full max-w-lg 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">Box Score</h3>
<span
className={cn(
"rounded-md px-2 py-0.5 text-xs font-medium",
final
? "bg-muted text-muted-foreground"
: "bg-destructive/10 text-destructive",
)}
>
{final ? "Final" : "Live"}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="px-4 py-2 text-left font-medium">Team</th>
{periodLabels.map((p) => (
<th
key={p}
className="w-12 px-2 py-2 text-center font-medium tabular-nums"
>
{p}
</th>
))}
<th className="w-14 px-3 py-2 text-center font-semibold text-foreground">
T
</th>
</tr>
</thead>
<tbody>
{rows.map((team, r) => (
<tr
key={team.code}
className="border-t border-border/70"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full"
style={{ background: team.color ?? "var(--muted-foreground)" }}
/>
<span className="font-semibold">{team.code}</span>
<span className="hidden text-muted-foreground @xs:inline">
{team.name}
</span>
</div>
</td>
{team.periods.map((pts, i) => (
<td
key={i}
className="px-2 py-2.5 text-center tabular-nums text-muted-foreground"
>
{pts}
</td>
))}
<td
className={cn(
"px-3 py-2.5 text-center text-base font-bold tabular-nums",
winner === r ? "text-foreground" : "text-muted-foreground",
)}
>
{totals[r]}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}