A live win-probability chart that swings between two teams over game time, hand-built in SVG with a traced line reveal.
npx shadcn@latest add @paragon/win-probability"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>
);
}