Examples.

16 small apps from the repository, each running here exactly as it does on its own, with all of its code. To run them locally, clone the repository and run bun run examples.

Showcases

Flags, seeds and a live minute. Cards open into stats and scorers, a right-click menu follows a team or sets a reminder, and Replay brings the results back in one by one. Built with Tailwind and shadcn/ui.

Nations Cup knockouts

Open a match for its stats, or right-click it for more.

Quarterfinals

  1. Fri 3 Jul, 18:00Full time
    1Brazil2
    8Morocco1
    61%Possession39%
    17Shots7
    7On target3
    • Rocha23'
    • Benali51'
    • Lima88'

    Harbour Stadium

  2. Fri 3 Jul, 21:00Full time
    4France1
    5Japan2
    57%Possession43%
    12Shots11
    4On target5
    • Martin12'
    • Sato64'
    • Ueda79'

    Northgate Park

  3. Sat 4 Jul, 18:00Full time
    2Argentina3
    7Netherlands1
    54%Possession46%
    15Shots9
    8On target2
    • Ríos9'
    • de Groot33'
    • Ríos58'
    • Paz90'

    River Arena

  4. Sat 4 Jul, 21:00Full time
    3Spain1
    6Portugal0
    66%Possession34%
    14Shots6
    5On target2
    • Navarro71'

    Old Town Ground

Semifinals

  1. Tue 7 Jul, 20:0067'
    1Brazil1
    5Japan1
    52%Possession48%
    9Shots8
    3On target4
    • Sato27'
    • Rocha55'

    Harbour Stadium

  2. Wed 8 Jul, 20:00Full time
    2Argentina2
    3Spain1
    47%Possession53%
    11Shots13
    6On target4
    • Navarro18'
    • Paz40'
    • Ríos82'

    Northgate Park

Final

  1. Sun 12 Jul, 19:00Upcoming
    To be decided
    2Argentina

    Kick-off Sun 12 Jul at 19:00.

    Harbour Stadium

Show the code
npm install @react-brackets/theme clsx lucide-react radix-ui react-brackets tailwind-merge
npx shadcn@latest add context-menu dropdown-menu
import { ChevronsDownUp, ChevronsUpDown, RotateCcw, SlidersHorizontal, Star } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { allMatches, Bracket } from 'react-brackets';
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from './components/ui/dropdown-menu';
import { nations, play, results } from './data';
import { cn } from './lib/utils';
import { MatchCard } from './match-card';

const button =
  'inline-flex h-9 items-center gap-2 rounded-md border bg-card px-3 text-sm font-medium shadow-xs transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50';

/** Adds or removes one id from a set, as a new set for React state. */
const toggled = (set: Set<string>, id: string, on = !set.has(id)) => {
  const next = new Set(set);
  if (on) next.add(id);
  else next.delete(id);
  return next;
};

export function App() {
  const [shown, setShown] = useState(results.length);
  const [expanded, setExpanded] = useState(() => new Set(['main.r2.m1']));
  const [reminders, setReminders] = useState(() => new Set(['main.r3.m1']));
  const [followed, setFollowed] = useState<string | null>(null);
  const [copied, setCopied] = useState<string | null>(null);
  const [flags, setFlags] = useState(true);
  const [seeds, setSeeds] = useState(true);
  const [minute, setMinute] = useState(67);
  const stage = useMemo(() => play(shown), [shown]);
  const replaying = shown < results.length;

  // A replay brings the results back in one at a time.
  useEffect(() => {
    if (!replaying) return;
    const timer = setTimeout(() => setShown(shown + 1), 900);
    return () => clearTimeout(timer);
  }, [shown, replaying]);

  // The live match keeps playing.
  useEffect(() => {
    const timer = setInterval(() => setMinute((current) => Math.min(90, current + 1)), 2000);
    return () => clearInterval(timer);
  }, []);

  const copy = (matchId: string) => {
    navigator.clipboard?.writeText(`${location.href.split('#')[0]}#${matchId}`);
    setCopied(matchId);
    setTimeout(() => setCopied(null), 1500);
  };

  return (
    <div className="grid grid-cols-1 gap-6">
      <header className="flex flex-wrap items-end justify-between gap-4">
        <div>
          <h2 className="text-xl font-bold">Nations Cup knockouts</h2>
          <p className="mt-1 text-sm text-muted-foreground">
            Open a match for its stats, or right-click it for more.
          </p>
        </div>
        <div className="flex gap-2">
          <button type="button" className={button} disabled={replaying} onClick={() => setShown(0)}>
            <RotateCcw
              className={cn('size-4', replaying && 'animate-spin [animation-direction:reverse]')}
            />
            Replay
          </button>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <button type="button" className={button}>
                <SlidersHorizontal className="size-4" />
                View
              </button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="w-52">
              <DropdownMenuLabel>Show</DropdownMenuLabel>
              <DropdownMenuCheckboxItem checked={flags} onCheckedChange={setFlags}>
                Flags
              </DropdownMenuCheckboxItem>
              <DropdownMenuCheckboxItem checked={seeds} onCheckedChange={setSeeds}>
                Seeds
              </DropdownMenuCheckboxItem>
              <DropdownMenuSeparator />
              <DropdownMenuSub>
                <DropdownMenuSubTrigger>
                  <Star />
                  Follow a team
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent className="w-48">
                  <DropdownMenuRadioGroup
                    value={followed ?? ''}
                    onValueChange={(value) => setFollowed(value || null)}
                  >
                    <DropdownMenuRadioItem value="">Nobody</DropdownMenuRadioItem>
                    {nations.map((nation) => (
                      <DropdownMenuRadioItem key={nation.id} value={nation.id}>
                        <span aria-hidden>{nation.flag}</span>
                        {nation.name}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuSubContent>
              </DropdownMenuSub>
              <DropdownMenuSeparator />
              <DropdownMenuItem
                onSelect={() => setExpanded(new Set(allMatches(stage).map((match) => match.id)))}
              >
                <ChevronsUpDown />
                Open every match
              </DropdownMenuItem>
              <DropdownMenuItem onSelect={() => setExpanded(new Set())}>
                <ChevronsDownUp />
                Close every match
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </header>
      <Bracket
        stage={stage}
        highlighted={followed ?? undefined}
        connectorRadius={10}
        className="[--rb-match-width:15rem]"
        renderMatch={(match) => (
          <MatchCard
            match={match}
            settings={{ flags, seeds, minute }}
            expanded={expanded.has(match.id)}
            reminder={reminders.has(match.id)}
            copied={copied === match.id}
            onToggle={() => setExpanded(toggled(expanded, match.id))}
            onRemind={(on) => setReminders(toggled(reminders, match.id, on))}
            onFollow={setFollowed}
            onCopy={() => copy(match.id)}
          />
        )}
      />
    </div>
  );
}

A dark arena in the teams' colours. Open a team for its roster with roles and KDA, play a series map by map, or play the whole bracket and crown a champion. Plain CSS modules and lucide icons.

Rift Masters

Open a team for its roster. Play a series, or all of them.

Quarterfinals

  1. Best of 3Upcoming
    • Haneul3.8 KDA
    • Jisoo4.6 KDA
    • Raon6.1 KDA
    • Dowon5.4 KDA
    • Sena4.9 KDA
    • Tuan2.4 KDA
    • Bao2.9 KDA
    • Khoa3.8 KDA
    • Minh4.1 KDA
    • Linh3.2 KDA
  2. Best of 3Upcoming
    • Rook2.9 KDA
    • Wisp3.6 KDA
    • Cipher4.8 KDA
    • Nova5.1 KDA
    • Atlas3.9 KDA
    • Brann3.1 KDA
    • Fjord3.3 KDA
    • Kaito4.6 KDA
    • Rune4.9 KDA
    • Ilse4.2 KDA
  3. Best of 3Upcoming
    • Vesper3.2 KDA
    • Kodiak4.1 KDA
    • Lumen5.7 KDA
    • Arrow6.3 KDA
    • Halcyon4.4 KDA
    • Comet2.6 KDA
    • Drift3.1 KDA
    • Quasar4.2 KDA
    • Ion4.5 KDA
    • Halo3.3 KDA
  4. Best of 3Upcoming
    • Yuheng3.5 KDA
    • Shan3.9 KDA
    • Lingxi5.2 KDA
    • Bai5.9 KDA
    • Qiu4.1 KDA
    • Tubarão2.7 KDA
    • Mata3.4 KDA
    • Raio4.4 KDA
    • Flecha4.7 KDA
    • Escudo3.6 KDA

Semifinals

  1. Best of 3Upcoming
  2. Best of 3Upcoming

Final

  1. Best of 3Upcoming
Show the code
npm install @react-brackets/theme lucide-react react-brackets
import { FastForward, RotateCcw, Trophy } from 'lucide-react';
import { type CSSProperties, useEffect, useState } from 'react';
import {
  allMatches,
  Bracket,
  getMatch,
  reportResult,
  type Stage,
  singleElimination,
} from 'react-brackets';
import styles from './app.module.css';
import { SeriesCard } from './series-card';
import { teamById, teams } from './teams';

const start = singleElimination(teams.map(({ id, name }) => ({ id, name })));

/** Plays one map of a series. The better seed is a little more likely to take it. */
function playMap(stage: Stage, matchId: string): Stage {
  const match = getMatch(stage, matchId);
  const [first, second] = match.slots.map((slot) => teamById.get(slot.participantId ?? ''));
  const edge = 0.5 + ((second?.seed ?? 0) - (first?.seed ?? 0)) * 0.04;
  const scores: [number, number] = [match.slots[0].score ?? 0, match.slots[1].score ?? 0];
  scores[Math.random() < edge ? 0 : 1] += 1;
  return reportResult(stage, matchId, { scores, live: Math.max(...scores) < 2 });
}

/** Opens or closes one roster, as a new map for React state. */
function toggled(open: Map<string, Set<0 | 1>>, matchId: string, slot: 0 | 1) {
  const slots = new Set(open.get(matchId));
  if (slots.has(slot)) slots.delete(slot);
  else slots.add(slot);
  return new Map(open).set(matchId, slots);
}

export function App() {
  const [stage, setStage] = useState(start);
  const [playing, setPlaying] = useState<string | null>(null);
  const [auto, setAuto] = useState(false);
  const [open, setOpen] = useState(() => new Map<string, Set<0 | 1>>());
  const matches = allMatches(stage);
  const final = matches.at(-1);
  const champion =
    final?.winner != null ? teamById.get(final.slots[final.winner].participantId ?? '') : undefined;

  // A series in progress: one map at a time.
  useEffect(() => {
    if (!playing) return;
    const timer = setTimeout(() => {
      const next = playMap(stage, playing);
      setStage(next);
      if (getMatch(next, playing).status === 'completed') setPlaying(null);
    }, 900);
    return () => clearTimeout(timer);
  }, [stage, playing]);

  // Playing the whole bracket: the next series starts once one ends.
  useEffect(() => {
    if (!auto || playing) return;
    const next = matches.find((match) => match.status === 'ready');
    if (next) setPlaying(next.id);
    else setAuto(false);
  }, [auto, playing, matches]);

  const replay = () => {
    setStage(start);
    setPlaying(null);
    setAuto(false);
  };

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Rift Masters</h2>
          <p className={styles.meta}>Open a team for its roster. Play a series, or all of them.</p>
        </div>
        <div className={styles.actions}>
          <button
            type="button"
            className={`${styles.button} ${styles.primary}`}
            disabled={auto || champion !== undefined}
            onClick={() => setAuto(true)}
          >
            <FastForward size={16} />
            Play all
          </button>
          <button type="button" className={styles.button} onClick={replay}>
            <RotateCcw size={16} />
            Replay
          </button>
        </div>
      </header>
      {champion && (
        <p className={styles.champion} style={{ '--team': champion.color } as CSSProperties}>
          <Trophy size={20} />
          <span>
            <strong>{champion.name}</strong> are the Rift Masters champions.
          </span>
        </p>
      )}
      <Bracket
        stage={stage}
        connectorRadius={10}
        className={styles.bracket}
        renderMatch={(match) => (
          <SeriesCard
            match={match}
            open={open.get(match.id) ?? new Set()}
            onToggle={(slot) => setOpen(toggled(open, match.id, slot))}
            onPlay={() => setPlaying(match.id)}
          />
        )}
      />
    </div>
  );
}

Start

A stage from a list of teams, passed to one component. The whole quick start.

Quarterfinals

  1. Falcons
    Cobras
  2. Lynx
    Orcas
  3. Wolves
    Bison
  4. Herons
    Ravens

Semifinals

  1. TBD
    TBD
  2. TBD
    TBD

Final

  1. TBD
    TBD
Show the code
npm install @react-brackets/theme react-brackets
import { Bracket, singleElimination } from 'react-brackets';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = singleElimination(teams.map((name) => ({ id: name, name })));

export function App() {
  return <Bracket stage={stage} />;
}

A server component builds the stage with @react-brackets/core, and the bracket hydrates on the client.

Show the code
npm install @react-brackets/core @react-brackets/theme react-brackets
import { reportResult, singleElimination } from '@react-brackets/core';
import { Bracket } from 'react-brackets';

// Server components build the stage with @react-brackets/core; <Bracket> runs on the client.
const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens'];

export default function Page() {
  const stage = reportResult(
    singleElimination(teams.map((name) => ({ id: name, name }))),
    'main.r1.m2',
    { scores: [2, 0] },
  );

  return <Bracket stage={stage} connectorRadius={8} />;
}

Formats

Double elimination

examples/double-elimination

Winners and losers brackets and a grand final that can reset, in tabs built from Bracket.Root and its parts.

Harbour Invitational

Double elimination. A team is out after its second loss.

Quarterfinals

  1. Falcons2
    Cobras0
  2. Lynx1
    Orcas2
  3. Wolves2
    Bison1
  4. Herons0
    Ravens2

Semifinals

  1. Falcons2
    Orcas1
  2. Wolves
    Ravens

Winners final

  1. Falcons
    TBD

Grand final

  1. TBD
    TBD

Reset

  1. TBD
    TBD
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import { Bracket, doubleElimination, reportResult, type Stage } from 'react-brackets';
import styles from './app.module.css';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const results: [string, [number, number]][] = [
  ['main.wb.r1.m1', [2, 0]],
  ['main.wb.r1.m2', [1, 2]],
  ['main.wb.r1.m3', [2, 1]],
  ['main.wb.r1.m4', [0, 2]],
  ['main.wb.r2.m1', [2, 1]],
  ['main.lb.r1.m1', [2, 0]],
  ['main.lb.r1.m2', [1, 2]],
];

const stage = results.reduce<Stage>(
  (current, [matchId, scores]) => reportResult(current, matchId, { scores }),
  doubleElimination(teams.map((name) => ({ id: name, name }))),
);

// The grand final continues the winners bracket, so they share a tab.
const winners = { label: 'Winners and final', groups: ['main.wb', 'main.gf'] };
const losers = { label: 'Losers', groups: ['main.lb'] };

export function App() {
  const [view, setView] = useState(winners);

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Harbour Invitational</h2>
          <p className={styles.meta}>Double elimination. A team is out after its second loss.</p>
        </div>
        <fieldset className={styles.switch} aria-label="Bracket">
          {[winners, losers].map((option) => (
            <button
              key={option.label}
              type="button"
              aria-pressed={option === view}
              onClick={() => setView(option)}
            >
              {option.label}
            </button>
          ))}
        </fieldset>
      </header>
      <Bracket.Root key={view.label} stage={stage} group={view.groups}>
        <Bracket.Viewport>
          <Bracket.Rounds />
          <Bracket.Connectors radius={8} />
        </Bracket.Viewport>
        <Bracket.Pagination />
      </Bracket.Root>
    </div>
  );
}

Two-sided bracket

examples/two-sided

Sixteen teams in two halves that meet in the final. Follow a team to light up its path.

City Championship

Sixteen teams, two halves, one final in the middle.

Round 1

  1. Falcons0
    Vipers2
  2. Cobras2
    Hawks1
  3. Lynx2
    Sharks1
  4. Orcas0
    Pumas2

Quarterfinals

  1. Vipers
    Cobras
  2. Lynx
    Pumas

Semifinals

  1. TBD
    TBD

Final

  1. TBD
    TBD

Semifinals

  1. TBD
    TBD

Quarterfinals

  1. Wolves
    Bison
  2. Owls
    Ravens

Round 1

  1. Wolves2
    Moose1
  2. Bison2
    Otters1
  3. Herons0
    Owls2
  4. Ravens2
    Cranes1
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import { Bracket, reportResult, type Stage, singleElimination } from 'react-brackets';
import styles from './app.module.css';

const teams = [
  'Falcons',
  'Wolves',
  'Herons',
  'Lynx',
  'Orcas',
  'Ravens',
  'Bison',
  'Cobras',
  'Hawks',
  'Otters',
  'Cranes',
  'Pumas',
  'Sharks',
  'Owls',
  'Moose',
  'Vipers',
];

const firstRound = Array.from({ length: 8 }, (_, index) => `main.r1.m${index + 1}`);

const stage = firstRound.reduce<Stage>(
  (current, matchId, index) =>
    reportResult(current, matchId, { scores: index % 3 ? [2, 1] : [0, 2] }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

export function App() {
  const [team, setTeam] = useState('Lynx');

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>City Championship</h2>
          <p className={styles.meta}>Sixteen teams, two halves, one final in the middle.</p>
        </div>
        <label className={styles.follow}>
          Follow
          <span className={styles.picker}>
            <select value={team} onChange={(event) => setTeam(event.target.value)}>
              <option value="">Nobody</option>
              {teams.map((name) => (
                <option key={name}>{name}</option>
              ))}
            </select>
          </span>
        </label>
      </header>
      <Bracket
        stage={stage}
        layout="mirrored"
        mode="fit"
        highlighted={team || null}
        connectorRadius={8}
        className={styles.bracket}
      />
    </div>
  );
}

Everyone plays everyone. Play the season a round at a time and watch the table move.

Sunday League

2 of 5 rounds played. Three points for a win, one for a draw.

#TeamPWDL+/-Pts
1Orcas211014
2Wolves210103
3Herons210103
4Lynx210103
5Falcons202002
6Ravens2011-11
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import { allMatches, Bracket, reportResult, roundRobin, type Stage } from 'react-brackets';
import styles from './app.module.css';
import { StandingsTable } from './standings-table';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens'];

const goals = () => Math.floor(Math.random() * 4);

/** Plays every match of the first round that still has matches to play. */
function playRound(stage: Stage): Stage {
  const round = stage.groups[0]?.rounds.find((candidate) =>
    candidate.matches.some((match) => match.status === 'ready'),
  );
  return (round?.matches ?? []).reduce(
    (current, match) => reportResult(current, match.id, { scores: [goals(), goals()] }),
    stage,
  );
}

const scoreFor = (index: number): [number, number] => [index % 3, (index * 2) % 3];

const initial = roundRobin(teams.map((name) => ({ id: name, name })));

// The first two rounds are already played, so the table has something to show.
const start = allMatches(initial)
  .slice(0, 6)
  .reduce<Stage>(
    (current, match, index) => reportResult(current, match.id, { scores: scoreFor(index) }),
    initial,
  );

export function App() {
  const [stage, setStage] = useState(start);
  const [view, setView] = useState<'table' | 'matches'>('table');
  const rounds = stage.groups[0]?.rounds ?? [];
  const played = rounds.filter((round) =>
    round.matches.every((match) => match.status === 'completed'),
  ).length;

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Sunday League</h2>
          <p className={styles.meta}>
            {played} of {rounds.length} rounds played. Three points for a win, one for a draw.
          </p>
        </div>
        <div className={styles.actions}>
          <fieldset className={styles.switch} aria-label="View">
            <button type="button" aria-pressed={view === 'table'} onClick={() => setView('table')}>
              Table
            </button>
            <button
              type="button"
              aria-pressed={view === 'matches'}
              onClick={() => setView('matches')}
            >
              Matches
            </button>
          </fieldset>
          <button
            type="button"
            className={styles.button}
            disabled={played === rounds.length}
            onClick={() => setStage(playRound(stage))}
          >
            {played === rounds.length ? 'Season over' : `Play round ${played + 1}`}
          </button>
          <button type="button" className={styles.button} onClick={() => setStage(start)}>
            Reset
          </button>
        </div>
      </header>
      {view === 'table' ? (
        <StandingsTable stage={stage} className={styles.standings} />
      ) : (
        <Bracket stage={stage} />
      )}
    </div>
  );
}

Each round is paired from the standings so far. Pair a round, play it, repeat.

Club Rapid

Round 1 of 4. Players on the same score meet next.

#TeamPWDL+/-Pts
1Ivo110003
2Ana000000
3Ben000000
4Chloe000000
5Dev000000
6Eli000000
7Farah000000
8Gus000000
9Hana000000

Swiss

Round 1

  1. Ana
    Eli
  2. Ben
    Farah
  3. Chloe
    Gus
  4. Dev
    Hana
  5. Ivo
    Bye
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import {
  allMatches,
  Bracket,
  nextSwissRound,
  reportResult,
  type Stage,
  swiss,
  swissRoundsLeft,
} from 'react-brackets';
import styles from './app.module.css';
import { StandingsTable } from './standings-table';

const players = ['Ana', 'Ben', 'Chloe', 'Dev', 'Eli', 'Farah', 'Gus', 'Hana', 'Ivo'];

const start = swiss(players.map((name) => ({ id: name, name })));

const simulate = (stage: Stage): Stage =>
  allMatches(stage)
    .filter((match) => match.status === 'ready')
    .reduce(
      (current, match) =>
        reportResult(current, match.id, { scores: Math.random() < 0.5 ? [1, 0] : [0, 1] }),
      stage,
    );

export function App() {
  const [stage, setStage] = useState(start);
  const round = stage.groups[0]?.rounds.length ?? 0;
  const roundOpen = allMatches(stage).some((match) => match.status === 'ready');
  const left = swissRoundsLeft(stage);
  const finished = !roundOpen && left === 0;

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Club Rapid</h2>
          <p className={styles.meta}>
            {finished
              ? 'All rounds played.'
              : `Round ${round} of ${round + left}. Players on the same score meet next.`}
          </p>
        </div>
        <div className={styles.actions}>
          <button
            type="button"
            className={styles.button}
            disabled={finished}
            onClick={() => setStage(roundOpen ? simulate(stage) : nextSwissRound(stage))}
          >
            {finished ? 'Finished' : roundOpen ? `Play round ${round}` : `Pair round ${round + 1}`}
          </button>
          <button type="button" className={styles.button} onClick={() => setStage(start)}>
            Reset
          </button>
        </div>
      </header>
      <div className={styles.body}>
        <StandingsTable stage={stage} className={styles.standings} />
        <Bracket stage={stage} labels={{ bye: 'Bye' }} />
      </div>
    </div>
  );
}

Groups to playoffs

examples/groups-to-playoffs

Two groups whose top two teams seed a knockout bracket with a third-place match.

Regional Cup

The top two in each group go through to the semifinals.

Group A

#TeamPWDL+/-Pts
1Orcas330059
2Lynx320116
3Cobras3102-23
4Falcons3003-40

Group B

#TeamPWDL+/-Pts
1Ravens330059
2Herons320116
3Bison3102-23
4Wolves3003-40
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import {
  allMatches,
  Bracket,
  qualifiers,
  reportResult,
  roundRobin,
  type Stage,
  singleElimination,
} from 'react-brackets';
import styles from './app.module.css';
import { StandingsTable } from './standings-table';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const draw = roundRobin(
  teams.map((name) => ({ id: name, name })),
  { groups: 2 },
);

const groups = allMatches(draw).reduce<Stage>(
  (current, match, index) =>
    reportResult(current, match.id, { scores: [index % 3, (index + 1) % 3] }),
  draw,
);

// Group winners meet the other group's runner-up in the semifinals.
const playoffs = singleElimination(qualifiers(groups, 2), { id: 'playoffs', thirdPlace: true });

export function App() {
  const [view, setView] = useState<'groups' | 'playoffs'>('groups');

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Regional Cup</h2>
          <p className={styles.meta}>The top two in each group go through to the semifinals.</p>
        </div>
        <fieldset className={styles.switch} aria-label="Stage">
          <button type="button" aria-pressed={view === 'groups'} onClick={() => setView('groups')}>
            Groups
          </button>
          <button
            type="button"
            aria-pressed={view === 'playoffs'}
            onClick={() => setView('playoffs')}
          >
            Playoffs
          </button>
        </fieldset>
      </header>
      {view === 'groups' ? (
        <div className={styles.groups}>
          {groups.groups.map((group) => (
            <section key={group.id} className={styles.group}>
              <h3>{group.name}</h3>
              <StandingsTable stage={groups} groupId={group.id} className={styles.standings} />
            </section>
          ))}
        </div>
      ) : (
        <Bracket stage={playoffs} connectorRadius={8} />
      )}
    </div>
  );
}

Styling

Theme and dark mode

examples/theme

The optional stylesheet, recoloured with a few CSS variables. Pick an accent, the corners, and light or dark.

Autumn Classic

The default theme, recoloured with a few CSS variables.

Quarterfinals

  1. Falcons3
    Cobras1
  2. Lynx0
    Orcas2
  3. Wolves2
    Bison1
  4. Herons1
    Ravens3

Semifinals

  1. Falcons2
    Orcas0
  2. Wolves1
    Ravens1

Final

  1. Falcons
    TBD
Show the code
npm install @react-brackets/theme react-brackets
import { type CSSProperties, useState } from 'react';
import { Bracket, reportResult, type Stage, singleElimination } from 'react-brackets';
import styles from './app.module.css';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const results: [string, [number, number]][] = [
  ['main.r1.m1', [3, 1]],
  ['main.r1.m2', [0, 2]],
  ['main.r1.m3', [2, 1]],
  ['main.r1.m4', [1, 3]],
  ['main.r2.m1', [2, 0]],
];

const stage = reportResult(
  results.reduce<Stage>(
    (current, [matchId, scores]) => reportResult(current, matchId, { scores }),
    singleElimination(teams.map((name) => ({ id: name, name }))),
  ),
  'main.r2.m2',
  { scores: [1, 1], live: true },
);

const accents = [
  { name: 'Ember', color: '#ea580c' },
  { name: 'Grape', color: '#7c3aed' },
  { name: 'Lagoon', color: '#0d9488' },
  { name: 'Cobalt', color: '#2563eb' },
];

const corners = [
  { name: 'Sharp', radius: '2px' },
  { name: 'Soft', radius: '10px' },
  { name: 'Round', radius: '18px' },
];

export function App() {
  const [accent, setAccent] = useState('#ea580c');
  const [radius, setRadius] = useState('10px');
  const [scheme, setScheme] = useState<'light' | 'dark'>('dark');

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Autumn Classic</h2>
          <p className={styles.meta}>The default theme, recoloured with a few CSS variables.</p>
        </div>
        <div className={styles.controls}>
          <fieldset className={styles.swatches} aria-label="Accent">
            {accents.map(({ name, color }) => (
              <button
                key={name}
                type="button"
                aria-label={name}
                aria-pressed={accent === color}
                style={{ background: color }}
                onClick={() => setAccent(color)}
              />
            ))}
          </fieldset>
          <fieldset className={styles.switch} aria-label="Corners">
            {corners.map((corner) => (
              <button
                key={corner.name}
                type="button"
                aria-pressed={radius === corner.radius}
                onClick={() => setRadius(corner.radius)}
              >
                {corner.name}
              </button>
            ))}
          </fieldset>
          <fieldset className={styles.switch} aria-label="Colour scheme">
            {(['light', 'dark'] as const).map((option) => (
              <button
                key={option}
                type="button"
                aria-pressed={scheme === option}
                onClick={() => setScheme(option)}
              >
                {option === 'light' ? 'Light' : 'Dark'}
              </button>
            ))}
          </fieldset>
        </div>
      </header>
      <div className={styles.canvas} data-scheme={scheme}>
        <Bracket
          stage={stage}
          className={styles.brand}
          connectorRadius={8}
          style={{ '--rb-accent': accent, '--rb-radius': radius } as CSSProperties}
        />
      </div>
    </div>
  );
}

Tailwind CSS

examples/tailwind

No stylesheet of ours at all: Tailwind classes on every part, keyed off its state.

Night Court Open

Styled with Tailwind classes only.

Quarterfinals under way

Quarterfinals

  1. Falcons3
    Cobras1
  2. Lynx
    Orcas
  3. Wolves
    Bison
  4. Herons
    Ravens

Semifinals

  1. Falcons
    TBD
  2. TBD
    TBD

Final

  1. TBD
    TBD
Show the code
npm install react-brackets
import { Bracket, type ClassNames, reportResult, singleElimination } from 'react-brackets';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];

const stage = reportResult(
  singleElimination(teams.map((name) => ({ id: name, name }))),
  'main.r1.m1',
  { scores: [3, 1] },
);

const classNames: ClassNames = {
  root: 'text-sm text-zinc-800 dark:text-zinc-100',
  content: 'gap-x-12',
  'round-title': 'pb-3 text-center text-xs font-semibold uppercase tracking-wide text-zinc-500',
  cell: 'py-1.5',
  match:
    'w-52 overflow-hidden rounded-xl bg-white ring-1 ring-zinc-200 shadow-sm data-highlighted:ring-indigo-500 dark:bg-zinc-900 dark:ring-zinc-700',
  participant:
    'flex items-center justify-between px-3 py-2 not-first:border-t not-first:border-zinc-100 data-winner:font-semibold data-loser:text-zinc-400 data-empty:text-zinc-400 data-highlighted:bg-indigo-50 dark:not-first:border-zinc-800 dark:data-highlighted:bg-indigo-950',
  'participant-score': 'tabular-nums',
  connector: 'stroke-zinc-300 data-highlighted:stroke-indigo-500 dark:stroke-zinc-600',
  pagination: 'mt-3 flex gap-1 overflow-x-auto',
  page: 'rounded-full px-3 py-1 text-xs text-zinc-500 data-active:bg-zinc-900 data-active:text-white',
  'page-previous': 'px-2 text-zinc-500 disabled:opacity-30 rtl:-scale-x-100',
  'page-next': 'px-2 text-zinc-500 disabled:opacity-30 rtl:-scale-x-100',
};

export function App() {
  return (
    <div className="grid grid-cols-1 gap-6 rounded-2xl bg-white p-6 ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
      <header className="flex flex-wrap items-end justify-between gap-4">
        <div>
          <h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-50">Night Court Open</h2>
          <p className="mt-1 text-sm text-zinc-500">Styled with Tailwind classes only.</p>
        </div>
        <span className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
          <span className="size-1.5 rounded-full bg-current" />
          Quarterfinals under way
        </span>
      </header>
      <Bracket stage={stage} classNames={classNames} connectorRadius={6} />
    </div>
  );
}

Custom match card

examples/custom-match

Your own card with kickoff times, team badges and a live marker. Pick a match to see its details.

Harbour League Finals

Pick a match to see its details.

Semifinals

  1. Fri 19:00
    FFalcons78
    LLynx71
  2. Fri 21:30Live
    WWolves64
    HHerons64

Final

  1. Sun 20:00
    FFalcons–
    ?To be decided–
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import {
  Bracket,
  type Match,
  participantMap,
  reportResult,
  type Stage,
  singleElimination,
} from 'react-brackets';
import styles from './app.module.css';

const teams = [
  { id: 'fal', name: 'Falcons', color: '#f97316', city: 'Harbourside' },
  { id: 'wol', name: 'Wolves', color: '#6366f1', city: 'Northgate' },
  { id: 'her', name: 'Herons', color: '#14b8a6', city: 'Riverbend' },
  { id: 'lyn', name: 'Lynx', color: '#eab308', city: 'Old Town' },
];

let stage: Stage = singleElimination(
  teams.map(({ id, name, color, city }) => ({ id, name, meta: { color, city } })),
);
stage = reportResult(stage, 'main.r1.m1', { scores: [78, 71] });
stage = reportResult(stage, 'main.r1.m2', { scores: [64, 64], live: true });

const names = participantMap(stage);

const fixtures: Record<string, { kickoff: string; venue: string }> = {
  'main.r1.m1': { kickoff: 'Fri 19:00', venue: 'Pier Arena' },
  'main.r1.m2': { kickoff: 'Fri 21:30', venue: 'Northgate Hall' },
  'main.r2.m1': { kickoff: 'Sun 20:00', venue: 'Pier Arena' },
};

const statusLabel: Record<Match['status'], string> = {
  waiting: 'Waiting for teams',
  ready: 'Up next',
  live: 'Live',
  completed: 'Full time',
  skipped: 'Bye',
};

function MatchCard({ match, selected }: { match: Match; selected: boolean }) {
  return (
    <Bracket.Match className={styles.card} data-selected={selected || undefined}>
      <header>
        <span>{fixtures[match.id]?.kickoff}</span>
        {match.status === 'live' && <span className={styles.live}>Live</span>}
      </header>
      {([0, 1] as const).map((slot) => (
        <Bracket.Participant key={slot} slot={slot} className={styles.team}>
          {({ participant, slot: { score } }) => (
            <>
              <Badge color={participant?.meta?.color} name={participant?.name} />
              <span className={styles.name}>{participant?.name ?? 'To be decided'}</span>
              <span className={styles.score}>{score ?? '–'}</span>
            </>
          )}
        </Bracket.Participant>
      ))}
    </Bracket.Match>
  );
}

function Badge({ color, name }: { color: unknown; name?: string }) {
  return (
    <span className={styles.badge} style={{ background: String(color ?? '#a1a1aa') }}>
      {name?.slice(0, 1) ?? '?'}
    </span>
  );
}

export function App() {
  const [selected, setSelected] = useState<Match | undefined>(
    stage.groups[0]?.rounds[0]?.matches[1],
  );
  const fixture = selected && fixtures[selected.id];

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <h2 className={styles.title}>Harbour League Finals</h2>
        <p className={styles.meta}>Pick a match to see its details.</p>
      </header>
      <div className={styles.body}>
        <Bracket
          stage={stage}
          connectorRadius={10}
          renderMatch={(match) => <MatchCard match={match} selected={match.id === selected?.id} />}
          onMatchClick={setSelected}
        />
        {selected && (
          <aside className={styles.detail} aria-live="polite">
            <p className={styles.status} data-status={selected.status}>
              {statusLabel[selected.status]}
            </p>
            {selected.slots.map((slot, index) => {
              const team = slot.participantId ? names.get(slot.participantId) : undefined;
              return (
                // biome-ignore lint/suspicious/noArrayIndexKey: a match always has the same two slots
                <div key={index} className={styles.side}>
                  <Badge color={team?.meta?.color} name={team?.name} />
                  <div>
                    <strong>{team?.name ?? 'To be decided'}</strong>
                    <span>{String(team?.meta?.city ?? '')}</span>
                  </div>
                  <span className={styles.big}>{slot.score ?? '–'}</span>
                </div>
              );
            })}
            {fixture && (
              <dl>
                <dt>Kickoff</dt>
                <dd>{fixture.kickoff}</dd>
                <dt>Venue</dt>
                <dd>{fixture.venue}</dd>
              </dl>
            )}
          </aside>
        )}
      </div>
    </div>
  );
}

Right to left

examples/rtl

The same cup in Arabic, right to left, or in English. Round names and labels are yours to translate.

كأس الأبطال

ثمانية أندية، مباراة واحدة لكل دور.

ربع النهائي

  1. الصقور2
    الكوبرا1
  2. الوشق
    الحيتان
  3. الذئاب
    الثيران
  4. البلشون
    الغربان

نصف النهائي

  1. الصقور
    لم يحدد
  2. لم يحدد
    لم يحدد

النهائي

  1. لم يحدد
    لم يحدد
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import { Bracket, type Labels, reportResult, singleElimination } from 'react-brackets';
import styles from './app.module.css';

interface Language {
  dir: 'rtl' | 'ltr';
  lang: string;
  name: string;
  title: string;
  meta: string;
  clubs: string[];
  rounds: string[];
  labels: Partial<Labels>;
}

const arabic: Language = {
  dir: 'rtl',
  lang: 'ar',
  name: 'العربية',
  title: 'كأس الأبطال',
  meta: 'ثمانية أندية، مباراة واحدة لكل دور.',
  clubs: ['الصقور', 'الذئاب', 'البلشون', 'الوشق', 'الحيتان', 'الغربان', 'الثيران', 'الكوبرا'],
  rounds: ['ربع النهائي', 'نصف النهائي', 'النهائي'],
  labels: {
    tbd: 'لم يحدد',
    bye: 'تأهل مباشر',
    previous: 'الدور السابق',
    next: 'الدور التالي',
    rounds: 'الأدوار',
  },
};

const english: Language = {
  dir: 'ltr',
  lang: 'en',
  name: 'English',
  title: 'Champions Cup',
  meta: 'Eight clubs, one match a round.',
  clubs: ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'],
  rounds: ['Quarterfinals', 'Semifinals', 'Final'],
  labels: {},
};

function cup({ clubs, rounds }: Language) {
  return reportResult(
    singleElimination(
      clubs.map((name, index) => ({ id: `club-${index + 1}`, name })),
      { roundName: (index) => rounds[index] ?? `${index + 1}` },
    ),
    'main.r1.m1',
    { scores: [2, 1] },
  );
}

export function App() {
  const [language, setLanguage] = useState(arabic);

  return (
    <div dir={language.dir} lang={language.lang} className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>{language.title}</h2>
          <p className={styles.meta}>{language.meta}</p>
        </div>
        <fieldset className={styles.switch} aria-label="Language">
          {[arabic, english].map((option) => (
            <button
              key={option.lang}
              type="button"
              lang={option.lang}
              aria-pressed={option === language}
              onClick={() => setLanguage(option)}
            >
              {option.name}
            </button>
          ))}
        </fieldset>
      </header>
      <Bracket stage={cup(language)} connectorRadius={8} labels={language.labels} />
    </div>
  );
}

Screens and data

Full bracket on every screen

examples/full-bracket

Keep the whole tree on a phone: scroll it, fit it to the width, or pan and pinch to zoom.

National Finals

Scaled down so the whole tree fits the width.

Round 1

  1. Falcons0
    Vipers2
  2. Cobras2
    Hawks1
  3. Lynx2
    Sharks1
  4. Orcas0
    Pumas2
  5. Wolves2
    Moose1
  6. Bison2
    Otters1
  7. Herons0
    Owls2
  8. Ravens2
    Cranes1

Quarterfinals

  1. Vipers
    Cobras
  2. Lynx
    Pumas
  3. Wolves
    Bison
  4. Owls
    Ravens

Semifinals

  1. TBD
    TBD
  2. TBD
    TBD

Final

  1. TBD
    TBD
Show the code
npm install @react-brackets/theme react-brackets
import { useState } from 'react';
import {
  Bracket,
  reportResult,
  type Stage,
  singleElimination,
  type ViewportMode,
} from 'react-brackets';
import styles from './app.module.css';

const teams = [
  'Falcons',
  'Wolves',
  'Herons',
  'Lynx',
  'Orcas',
  'Ravens',
  'Bison',
  'Cobras',
  'Hawks',
  'Otters',
  'Cranes',
  'Pumas',
  'Sharks',
  'Owls',
  'Moose',
  'Vipers',
];

const stage = Array.from({ length: 8 }, (_, index) => `main.r1.m${index + 1}`).reduce<Stage>(
  (current, matchId, index) =>
    reportResult(current, matchId, { scores: index % 3 ? [2, 1] : [0, 2] }),
  singleElimination(teams.map((name) => ({ id: name, name }))),
);

const modes: { mode: ViewportMode; name: string; note: string }[] = [
  { mode: 'scroll', name: 'Scroll', note: 'Scrolls sideways, like any wide table.' },
  { mode: 'fit', name: 'Fit', note: 'Scaled down so the whole tree fits the width.' },
  {
    mode: 'pan-zoom',
    name: 'Pan and zoom',
    note: 'Drag to move, pinch or ctrl/⌘ + scroll to zoom.',
  },
];

export function App() {
  const [mode, setMode] = useState<ViewportMode>('fit');
  const note = modes.find((option) => option.mode === mode)?.note;

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>National Finals</h2>
          <p className={styles.meta}>{note}</p>
        </div>
        <fieldset className={styles.switch} aria-label="Fit">
          {modes.map((option) => (
            <button
              key={option.mode}
              type="button"
              aria-pressed={option.mode === mode}
              onClick={() => setMode(option.mode)}
            >
              {option.name}
            </button>
          ))}
        </fieldset>
      </header>
      {/* compactBelow={0} keeps the whole tree, even on a phone. */}
      <Bracket key={mode} stage={stage} mode={mode} compactBelow={0} connectorRadius={8} />
    </div>
  );
}

Results stream in and winners move on as they land. Pause, restart, or click a match to follow it.

Show the code
npm install @react-brackets/theme react-brackets
import { useEffect, useState } from 'react';
import {
  allMatches,
  Bracket,
  type Match,
  participantMap,
  reportResult,
  type Stage,
  singleElimination,
} from 'react-brackets';
import styles from './app.module.css';

const teams = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];
const target = 5;

const start = singleElimination(teams.map((name) => ({ id: name, name })));

/** One scoring event per tick: a live match gains a point, or the next ready match kicks off. */
function tick(stage: Stage): Stage {
  const live = allMatches(stage).find((match) => match.status === 'live');
  if (!live) {
    const next = allMatches(stage).find((match) => match.status === 'ready');
    return next ? reportResult(stage, next.id, { scores: [0, 0], live: true }) : stage;
  }

  const side = Math.random() < 0.5 ? 0 : 1;
  const scores: [number, number] = [live.slots[0].score ?? 0, live.slots[1].score ?? 0];
  scores[side] += 1;
  return reportResult(stage, live.id, { scores, live: scores[side] < target });
}

export function App() {
  const [stage, setStage] = useState(start);
  const [playing, setPlaying] = useState(true);
  const [followed, setFollowed] = useState<string | null>(null);
  const names = participantMap(stage);
  const matches = allMatches(stage);
  const live = matches.find((match) => match.status === 'live');
  const finished = !live && !matches.some((match) => match.status === 'ready');
  const shown = matches.find((match) => match.id === followed) ?? live;

  useEffect(() => {
    if (!playing || finished) return;
    const timer = setInterval(() => setStage(tick), 700);
    return () => clearInterval(timer);
  }, [playing, finished]);

  const score = (match: Match) =>
    match.slots
      .map((slot) => `${names.get(slot.participantId ?? '')?.name ?? 'TBD'} ${slot.score ?? 0}`)
      .join(' – ');

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>
            Weekend Cup
            {live && <span className={styles.live}>Live</span>}
          </h2>
          <p className={styles.meta} aria-live="polite">
            {finished
              ? 'All matches played.'
              : shown
                ? `${followed ? 'Following' : 'Now'}: ${score(shown)}`
                : `First to ${target}. Click a match to follow it.`}
          </p>
        </div>
        <div className={styles.actions}>
          <button
            type="button"
            className={styles.button}
            disabled={finished}
            onClick={() => setPlaying(!playing)}
          >
            {playing ? 'Pause' : 'Resume'}
          </button>
          <button
            type="button"
            className={styles.button}
            onClick={() => {
              setStage(start);
              setFollowed(null);
              setPlaying(true);
            }}
          >
            Restart
          </button>
        </div>
      </header>
      <Bracket
        stage={stage}
        connectorRadius={8}
        className={styles.bracket}
        onMatchClick={(match) => setFollowed(match.id === followed ? null : match.id)}
      />
    </div>
  );
}

A skeleton at the bracket's real size while the draw loads, then the bracket in its place.

Show the code
npm install @react-brackets/theme react-brackets
import { type CSSProperties, useEffect, useState } from 'react';
import { Bracket, type Match, type Participant, singleElimination } from 'react-brackets';
import styles from './app.module.css';

const placeholders: Participant[] = Array.from({ length: 8 }, (_, index) => ({
  id: `placeholder-${index}`,
  name: '',
}));
const skeleton = singleElimination(placeholders);

function fetchTeams(): Promise<Participant[]> {
  const names = ['Falcons', 'Wolves', 'Herons', 'Lynx', 'Orcas', 'Ravens', 'Bison', 'Cobras'];
  return new Promise((resolve) =>
    setTimeout(() => resolve(names.map((name) => ({ id: name, name }))), 1500),
  );
}

const looks = [
  { id: 'pulse', name: 'Pulse' },
  { id: 'shimmer', name: 'Shimmer' },
  { id: 'wave', name: 'Wave' },
] as const;

type Look = (typeof looks)[number]['id'];

export function App() {
  const [teams, setTeams] = useState<Participant[] | null>(null);
  const [request, setRequest] = useState(0);
  const [look, setLook] = useState<Look>('shimmer');

  // biome-ignore lint/correctness/useExhaustiveDependencies: each request number loads the draw again
  useEffect(() => {
    let current = true;
    setTeams(null);
    fetchTeams().then((loaded) => current && setTeams(loaded));
    return () => {
      current = false;
    };
  }, [request]);

  return (
    <div className={styles.app}>
      <header className={styles.header}>
        <div>
          <h2 className={styles.title}>Spring Draw</h2>
          <p className={styles.meta} aria-live="polite">
            {teams ? 'Draw loaded.' : 'Loading the draw…'}
          </p>
        </div>
        <div className={styles.actions}>
          <fieldset className={styles.switch} aria-label="Loading animation">
            {looks.map((option) => (
              <button
                key={option.id}
                type="button"
                aria-pressed={look === option.id}
                onClick={() => {
                  setLook(option.id);
                  setRequest(request + 1);
                }}
              >
                {option.name}
              </button>
            ))}
          </fieldset>
          <button
            type="button"
            className={styles.button}
            disabled={teams === null}
            onClick={() => setRequest(request + 1)}
          >
            Reload
          </button>
        </div>
      </header>
      <div aria-busy={teams === null} data-look={teams ? undefined : look} className={styles.stage}>
        {teams ? (
          <Bracket stage={singleElimination(teams)} connectorRadius={8} className={styles.loaded} />
        ) : (
          <Bracket
            stage={skeleton}
            connectorRadius={8}
            highlightOnHover={false}
            renderMatch={SkeletonMatch}
          />
        )}
      </div>
    </div>
  );
}

/** Later rounds start a little later, so a wave can run across the bracket. */
function SkeletonMatch(match: Match) {
  const [, round = 1, row = 1] = match.id.match(/r(\d+)\.m(\d+)/)?.map(Number) ?? [];
  return (
    <Bracket.Match
      aria-hidden
      style={{ '--delay': `${(round - 1) * 240 + (row - 1) * 90}ms` } as CSSProperties}
    >
      <Bracket.Participant slot={0}>
        <span className={styles.skeleton}>Placeholder</span>
      </Bracket.Participant>
      <Bracket.Participant slot={1}>
        <span className={styles.skeleton}>Team</span>
      </Bracket.Participant>
    </Bracket.Match>
  );
}