Sports

Standings Table

A league standings table with rank, W-L record, win percentage, current streak, rank-movement arrows, and a playoff cutoff line.

Install

npx shadcn@latest add @paragon/standings-table

standings-table.tsx

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