A single-elimination bracket where the connector to a decided match draws itself in toward the next round.
npx shadcn@latest add @paragon/tournament-bracket"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface BracketTeam {
id: string;
name: string;
seed?: number;
}
export interface BracketMatch {
id: string;
a?: BracketTeam;
b?: BracketTeam;
/** id of the winning team, once decided. */
winnerId?: string;
}
export interface TournamentBracketProps extends React.ComponentProps<"div"> {
/** Rounds left to right; each round has half the matches of the prior. */
rounds: BracketMatch[][];
/** Round headings, one per round. */
roundLabels?: string[];
/** Renders decided connectors instantly, no draw-in. */
static?: boolean;
}
const MATCH_H = 56;
const MATCH_GAP = 24;
const COL_W = 168;
const CONNECTOR_W = 40;
/**
* A single-elimination bracket. Matches lay out in columns; an SVG overlay
* connects each match to its parent in the next round, and the connector to a
* decided match draws itself in (stroke-dashoffset transition) when a winner
* is set, tinting to the accent. Geometry is computed from fixed row metrics
* so the SVG paths always line up with the match cards. Reduced motion draws
* connectors instantly.
*/
export function TournamentBracket({
rounds,
roundLabels,
static: isStatic = false,
className,
...props
}: TournamentBracketProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
// Vertical center of match m in round r, in px from the top of the grid.
const centerY = React.useCallback((round: number, index: number) => {
const block = MATCH_H + MATCH_GAP;
const span = block * Math.pow(2, round);
return span / 2 + index * span - MATCH_GAP / 2;
}, []);
const totalHeight =
(rounds[0]?.length ?? 1) * (MATCH_H + MATCH_GAP) - MATCH_GAP;
const totalWidth = rounds.length * COL_W;
return (
<div
data-slot="tournament-bracket"
className={cn("w-full overflow-x-auto", className)}
{...props}
>
<div
className="relative"
style={{ width: totalWidth, minHeight: totalHeight + 28 }}
>
{roundLabels && (
<div
className="mb-2 flex text-xs font-medium text-muted-foreground"
style={{ width: totalWidth }}
>
{rounds.map((_, r) => (
<div key={r} style={{ width: COL_W }} className="px-1">
{roundLabels[r]}
</div>
))}
</div>
)}
<div className="relative" style={{ height: totalHeight }}>
{/* Connector overlay */}
<svg
aria-hidden
className="pointer-events-none absolute inset-0 text-border"
width={totalWidth}
height={totalHeight}
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
>
{rounds.slice(0, -1).map((matches, r) =>
matches.map((match, i) => {
const parentIndex = Math.floor(i / 2);
const x1 = (r + 1) * COL_W - CONNECTOR_W;
const x2 = (r + 1) * COL_W;
const y1 = centerY(r, i);
const y2 = centerY(r + 1, parentIndex);
const midX = x1 + CONNECTOR_W / 2;
const d = `M${x1} ${y1} H${midX} V${y2} H${x2}`;
const decided = !!match.winnerId;
return (
<Connector
key={match.id}
d={d}
decided={decided}
animate={animate}
delay={r * 120}
/>
);
}),
)}
</svg>
{rounds.map((matches, r) =>
matches.map((match, i) => (
<div
key={match.id}
className="absolute"
style={{
left: r * COL_W,
top: centerY(r, i) - MATCH_H / 2,
width: COL_W - CONNECTOR_W,
height: MATCH_H,
}}
>
<MatchCard match={match} />
</div>
)),
)}
</div>
</div>
</div>
);
}
function Connector({
d,
decided,
animate,
delay,
}: {
d: string;
decided: boolean;
animate: boolean;
delay: number;
}) {
const ref = React.useRef<SVGPathElement>(null);
const [length, setLength] = React.useState(0);
React.useLayoutEffect(() => {
if (ref.current) setLength(ref.current.getTotalLength());
}, [d]);
const drawn = decided && (!animate || length > 0);
return (
<path
ref={ref}
d={d}
fill="none"
strokeWidth={1.5}
stroke={decided ? "var(--primary)" : "currentColor"}
strokeOpacity={decided ? 0.9 : 0.5}
strokeDasharray={animate && length ? length : undefined}
strokeDashoffset={animate && length ? (drawn ? 0 : length) : 0}
style={
animate
? {
transition: `stroke-dashoffset 500ms var(--ease-out) ${delay}ms, stroke 200ms linear`,
}
: undefined
}
/>
);
}
function MatchCard({ match }: { match: BracketMatch }) {
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg bg-card shadow-border">
<Slot team={match.a} won={match.winnerId === match.a?.id} decided={!!match.winnerId} />
<div className="h-px bg-border" />
<Slot team={match.b} won={match.winnerId === match.b?.id} decided={!!match.winnerId} />
</div>
);
}
function Slot({
team,
won,
decided,
}: {
team?: BracketTeam;
won: boolean;
decided: boolean;
}) {
return (
<div
className={cn(
"flex flex-1 items-center gap-2 px-2.5 text-sm transition-colors duration-200",
won && "bg-accent font-semibold",
decided && !won && "text-muted-foreground",
)}
>
{team?.seed !== undefined && (
<span className="w-4 shrink-0 text-xs tabular-nums text-muted-foreground">
{team.seed}
</span>
)}
<span className="min-w-0 flex-1 truncate">
{team?.name ?? <span className="text-muted-foreground/50">—</span>}
</span>
</div>
);
}