Sports

Tournament Bracket

A single-elimination bracket where the connector to a decided match draws itself in toward the next round.

Install

npx shadcn@latest add @paragon/tournament-bracket

tournament-bracket.tsx

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