Sports

Win Probability

A live win-probability chart that swings between two teams over game time, hand-built in SVG with a traced line reveal.

Install

npx shadcn@latest add @paragon/win-probability

win-probability.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface WinProbPoint {
  /** Game progress 0–1 (0 = tip-off, 1 = final). */
  t: number;
  /** Home win probability 0–1. */
  p: number;
}

export interface WinProbabilityProps extends React.ComponentProps<"div"> {
  homeCode?: string;
  awayCode?: string;
  homeColor?: string;
  awayColor?: string;
  /** Ordered win-probability samples over the game. */
  points?: WinProbPoint[];
  /** Period boundary labels, evenly spaced across the x-axis. */
  periods?: string[];
}

const DEFAULT_POINTS: WinProbPoint[] = [
  { t: 0, p: 0.5 },
  { t: 0.12, p: 0.58 },
  { t: 0.24, p: 0.47 },
  { t: 0.36, p: 0.55 },
  { t: 0.48, p: 0.68 },
  { t: 0.58, p: 0.61 },
  { t: 0.68, p: 0.44 },
  { t: 0.78, p: 0.36 },
  { t: 0.86, p: 0.52 },
  { t: 0.93, p: 0.71 },
  { t: 1, p: 0.83 },
];

/**
 * A live win-probability chart that swings between the two teams over game
 * time. Hand-built SVG: the home team owns the area above the 50% line, the
 * away team below; the line is drawn once with a reduced-motion-guarded
 * "trace" reveal on mount. The endpoint reads out the current probability.
 * Deterministic — samples come from props.
 */
export function WinProbability({
  homeCode = "BOS",
  awayCode = "LAL",
  homeColor = "#008348",
  awayColor = "#552583",
  points = DEFAULT_POINTS,
  periods = ["Q1", "Q2", "Q3", "Q4"],
  className,
  ...props
}: WinProbabilityProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const W = 320;
  const H = 140;

  const coords = points.map((pt) => ({
    x: pt.t * W,
    y: (1 - pt.p) * H,
  }));
  const linePath = coords
    .map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(1)},${c.y.toFixed(1)}`)
    .join(" ");
  const homeArea = `${linePath} L${W},0 L0,0 Z`;
  const awayArea = `${linePath} L${W},${H} L0,${H} Z`;

  const last = points[points.length - 1];
  const homePct = Math.round(last.p * 100);
  const leader = last.p >= 0.5 ? homeCode : awayCode;
  const leaderPct = last.p >= 0.5 ? homePct : 100 - homePct;

  return (
    <div
      data-slot="win-probability"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-win-probability" precedence="paragon">{`
        @keyframes paragon-winprob-trace {
          from { stroke-dashoffset: var(--winprob-len); }
          to { stroke-dashoffset: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-winprob-line] { animation: none !important; stroke-dashoffset: 0 !important; }
        }
      `}</style>

      <div className="mb-3 flex items-center justify-between">
        <div>
          <h3 className="text-sm font-semibold">Win probability</h3>
          <p className="text-xs text-muted-foreground">
            <span className="font-semibold text-foreground">{leader}</span>{" "}
            <span className="tabular-nums">{leaderPct}%</span> to win
          </p>
        </div>
        <div className="flex flex-col items-end gap-1 text-xs">
          <span className="inline-flex items-center gap-1.5">
            <span className="size-2 rounded-full" style={{ background: homeColor }} />
            <span className="font-medium">{homeCode}</span>
          </span>
          <span className="inline-flex items-center gap-1.5">
            <span className="size-2 rounded-full" style={{ background: awayColor }} />
            <span className="font-medium">{awayCode}</span>
          </span>
        </div>
      </div>

      <div className="relative">
        <svg
          viewBox={`0 0 ${W} ${H}`}
          className="w-full"
          role="img"
          aria-label={`Win probability chart, ${leader} favored at ${leaderPct} percent`}
        >
          <defs>
            <linearGradient id={`home-${id}`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor={homeColor} stopOpacity="0.28" />
              <stop offset="100%" stopColor={homeColor} stopOpacity="0.02" />
            </linearGradient>
            <linearGradient id={`away-${id}`} x1="0" y1="1" x2="0" y2="0">
              <stop offset="0%" stopColor={awayColor} stopOpacity="0.28" />
              <stop offset="100%" stopColor={awayColor} stopOpacity="0.02" />
            </linearGradient>
          </defs>

          <path d={homeArea} fill={`url(#home-${id})`} />
          <path d={awayArea} fill={`url(#away-${id})`} />

          <line
            x1="0"
            y1={H / 2}
            x2={W}
            y2={H / 2}
            stroke="var(--color-border)"
            strokeWidth="1"
            strokeDasharray="3 3"
          />

          <path
            data-winprob-line
            d={linePath}
            fill="none"
            stroke="var(--color-foreground)"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
            pathLength={1000}
            style={
              {
                "--winprob-len": "1000",
                strokeDasharray: 1000,
                animation: "paragon-winprob-trace 1.1s var(--ease-out) forwards",
              } as React.CSSProperties
            }
          />

          <circle
            cx={coords[coords.length - 1].x}
            cy={coords[coords.length - 1].y}
            r="3.5"
            fill="var(--color-foreground)"
            stroke="var(--color-card)"
            strokeWidth="2"
          />
        </svg>
      </div>

      <div className="mt-2 flex justify-between text-[10px] font-medium tracking-wide text-muted-foreground">
        {periods.map((p) => (
          <span key={p} className="flex-1 text-center first:text-left last:text-right">
            {p}
          </span>
        ))}
      </div>
    </div>
  );
}