A league standings table with rank, W-L record, win percentage, current streak, rank-movement arrows, and a playoff cutoff line.
npx shadcn@latest add @paragon/standings-table"use client";
import * as React from "react";
import { Minus, TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StandingsRow {
code: string;
name: string;
wins: number;
losses: number;
/** Positive = win streak, negative = loss streak. */
streak: number;
/** Rank change vs. previous week: +up, -down, 0 flat. */
movement: number;
color?: string;
/** Marks a playoff cutoff line drawn below this row. */
clinchLine?: boolean;
}
export interface StandingsTableProps extends React.ComponentProps<"div"> {
title?: string;
rows?: StandingsRow[];
}
const DEFAULT_ROWS: StandingsRow[] = [
{ code: "BOS", name: "Celtics", wins: 51, losses: 14, streak: 5, movement: 0, color: "#008348" },
{ code: "NYK", name: "Knicks", wins: 44, losses: 21, streak: 2, movement: 1, color: "#f58426" },
{ code: "MIL", name: "Bucks", wins: 43, losses: 22, streak: -1, movement: -1, color: "#00471b" },
{ code: "CLE", name: "Cavaliers", wins: 41, losses: 24, streak: 3, movement: 2, color: "#860038" },
{ code: "ORL", name: "Magic", wins: 38, losses: 27, streak: -2, movement: -1, color: "#0077c0", clinchLine: true },
{ code: "MIA", name: "Heat", wins: 34, losses: 31, streak: 1, movement: 0, color: "#98002e" },
];
function pct(w: number, l: number) {
const g = w + l;
return g === 0 ? ".000" : (w / g).toFixed(3).replace(/^0/, "");
}
/**
* A league standings table with rank, W-L record, win percentage, current
* streak, and week-over-week rank movement arrows. A dashed line marks the
* playoff cutoff. Numbers are tabular; movement uses semantic success/
* destructive tokens. Static table — no motion.
*/
export function StandingsTable({
title = "Eastern Conference",
rows = DEFAULT_ROWS,
className,
...props
}: StandingsTableProps) {
return (
<div
data-slot="standings-table"
className={cn(
"w-full max-w-lg overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="border-b border-border px-4 py-2.5">
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="w-8 py-2 pl-4 text-left font-medium">#</th>
<th className="py-2 text-left font-medium">Team</th>
<th className="w-14 px-2 py-2 text-center font-medium">W</th>
<th className="w-14 px-2 py-2 text-center font-medium">L</th>
<th className="w-16 px-2 py-2 text-center font-medium">PCT</th>
<th className="w-16 px-2 py-2 text-center font-medium">Strk</th>
<th className="w-10 py-2 pr-4 text-center font-medium">+/-</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={row.code}
className={cn(
"border-t border-border/60",
row.clinchLine &&
"border-b-2 [border-bottom-style:dashed] border-b-border",
)}
>
<td className="py-2.5 pl-4 text-left font-semibold tabular-nums text-muted-foreground">
{i + 1}
</td>
<td className="py-2.5">
<div className="flex items-center gap-2">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full"
style={{ background: row.color ?? "var(--muted-foreground)" }}
/>
<span className="font-semibold">{row.code}</span>
<span className="hidden text-muted-foreground @sm:inline">
{row.name}
</span>
</div>
</td>
<td className="px-2 py-2.5 text-center tabular-nums">{row.wins}</td>
<td className="px-2 py-2.5 text-center tabular-nums text-muted-foreground">
{row.losses}
</td>
<td className="px-2 py-2.5 text-center font-mono tabular-nums text-muted-foreground">
{pct(row.wins, row.losses)}
</td>
<td className="px-2 py-2.5 text-center">
<span
className={cn(
"text-xs font-semibold tabular-nums",
row.streak > 0
? "text-success"
: row.streak < 0
? "text-destructive"
: "text-muted-foreground",
)}
>
{row.streak > 0 ? "W" : row.streak < 0 ? "L" : "—"}
{row.streak !== 0 ? Math.abs(row.streak) : ""}
</span>
</td>
<td className="py-2.5 pr-4">
<div className="flex items-center justify-center">
<Movement value={row.movement} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function Movement({ value }: { value: number }) {
if (value > 0)
return (
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-success">
<TrendingUp className="size-3.5" />
<span className="tabular-nums">{value}</span>
</span>
);
if (value < 0)
return (
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-destructive">
<TrendingDown className="size-3.5" />
<span className="tabular-nums">{Math.abs(value)}</span>
</span>
);
return (
<span className="text-muted-foreground/60">
<Minus className="size-3.5" />
</span>
);
}