Skip to content
txbrendan/ui

Knockout Bracket

beta

A knockout draw round by round, up to three rounds side by side, paged one round at a time with chevrons. Cards, elbows, and titles glide together.

Preview

finished, live, and to come, with a third-place play-off: press a match to choose it

Quarter-finals
Semi-finals
Final
Third-place play-off

sixteen sides: three rounds show, and the chevrons page on to the final

Round of 16
  1. Sat 5 Sep · 13:00 · Pitch 1RoversSaints
  2. Sat 5 Sep · 14:00 · Pitch 2LionsHawks
  3. Sat 5 Sep · 15:00 · Pitch 1WolvesFalcons
  4. Sat 5 Sep · 16:00 · Pitch 2BearsTigers
  5. Sat 5 Sep · 13:00 · Pitch 1Rovers CitySaints City
  6. Sat 5 Sep · 14:00 · Pitch 2Lions CityHawks City
  7. Sat 5 Sep · 15:00 · Pitch 1Wolves UnitedFalcons United
  8. Sat 5 Sep · 16:00 · Pitch 2Bears UnitedTigers United
Quarter-finals
  1. Sat 19 SepR16 1 winnerR16 2 winner
  2. Sat 19 SepR16 3 winnerR16 4 winner
  3. Sat 19 SepR16 5 winnerR16 6 winner
  4. Sat 19 SepR16 7 winnerR16 8 winner
Semi-finals
  1. Sat 3 OctQF 1 winnerQF 2 winner
  2. Sat 3 OctQF 3 winnerQF 4 winner
Final
  1. Sat 17 Oct · 15:00SF 1 winnerSF 2 winner

starts on the semi-finals: initialRound={1}

Quarter-finals
  1. Sat 3 Oct · 14:00FTRovers31Tigers12 Rovers won.
  2. Sat 3 Oct · 14:00AETBears14Lions14 Lions won.
  3. Sat 3 Oct · 15:30FT (P)Hawks(4)20Saints(3)20 Hawks won 4–3 on kicks.
  4. Sat 3 Oct · 15:30FTFalcons9Wolves22 Wolves won.
Semi-finals
  1. Sat 17 Oct · 15:20FTRovers24Lions17 Rovers won.
  2. Sat 17 Oct · 16:00 · Pitch 2Live 52'Hawks7Wolves3
Final
  1. Sat 24 Oct · 15:00RoversSF 2 winner

a narrow column shows one round

Quarter-finals
  1. Sat 3 Oct · 14:00FTRovers31Tigers12 Rovers won.
  2. Sat 3 Oct · 14:00AETBears14Lions14 Lions won.
  3. Sat 3 Oct · 15:30FT (P)Hawks(4)20Saints(3)20 Hawks won 4–3 on kicks.
  4. Sat 3 Oct · 15:30FTFalcons9Wolves22 Wolves won.
Semi-finals
  1. Sat 17 Oct · 15:20FTRovers24Lions17 Rovers won.
  2. Sat 17 Oct · 16:00 · Pitch 2Live 52'Hawks7Wolves3
Final
  1. Sat 24 Oct · 15:00RoversSF 2 winner

no rounds yet

No matches yet.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/knockout-bracket

Use it for

  • A cup or a play-off draw, from the widest round to the final.
  • Pass rounds widest first. rounds[r].matches[k] is fed by matches 2k and 2k+1 of the round before.
  • Give each side its team, or team: null with a label like "QF 3 winner" until it is known.
  • Pass thirdPlace to draw a third-place play-off under the final.
  • Pass onSelect, or a selectedId, to make each match a button you can choose, like for a match panel beside it.
  • It shows three rounds from 808px wide, two from 528px, and one below that. Each round is 240 to 300px wide, and the bracket never draws narrower than 240px.

Avoid it for

  • A league table or a round robin. Use a table, since nothing feeds anything.
  • A single fixture in a feed. Use BracketMatch, which adds a peek, or MatchCard.
  • The draw as a picture. Use KnockoutWheel for the circle.

Props

  • rounds

    Widest round first. Each is { name, matches }, and each match is BracketMatchData.

    BracketRound[]

  • initialRound

    The index of the leftmost round on mount. It pages on to the final, no further.

    number · default 0

  • thirdPlace

    A third-place play-off, drawn under the final.

    BracketMatchData

  • thirdPlaceLabel

    The title over the third-place match.

    string · default "Third-place play-off"

  • selectedId

    Controlled: the chosen match's id.

    string

  • defaultSelectedId

    The match chosen first, when you don't control it. It also makes each match a button.

    string

  • onSelect

    Makes each match a button, and hears the one chosen.

    (match: BracketMatchData) => void

  • className

    Extra classes for the root. Later classes win.

    string

Accessibility

  • The bracket is a group named "Knockout bracket". Pass aria-label to name it yourself.
  • Each round is a list, named by its title. The third-place match comes after the final.
  • Each match names both sides, the scores, and the winner in text. The winner's caret and weight only show it.
  • Every round stays in the reading order, even when it is paged out of view. Focus that lands in a hidden round pages to it.
  • The chevrons say "Previous round" and "Next round", and stay focusable at the ends. A page turn is said politely.
  • A chosen match is a button with aria-pressed, and a 2px outline that shows in forced colors.
  • The elbows are hidden from screen readers.

Examples

Semi-finals into a final, with sides not known yet

// rounds come from your data. Each side is { team: { name, colors?, pattern? } | null, score, penalties?, label? }.
<KnockoutBracket
  rounds={[
    {
      name: "Semi-finals",
      matches: [
        { id: "sf1", status: "finished", home: { team: { name: "Rovers" }, score: 24 }, away: { team: { name: "Lions" }, score: 17 } },
        { id: "sf2", date: "Sat 17 Oct", time: "15:20", home: { team: { name: "Hawks" }, score: null }, away: { team: { name: "Wolves" }, score: null } },
      ],
    },
    {
      name: "Final",
      matches: [{ id: "f", date: "Sat 24 Oct", time: "15:00", home: { team: null, label: "SF 1 winner", score: null }, away: { team: null, label: "SF 2 winner", score: null } }],
    },
  ]}
/>

Choose a match

const [selected, setSelected] = useState("sf1");

<KnockoutBracket rounds={rounds} selectedId={selected} onSelect={(match) => setSelected(match.id)} />

With a third-place play-off

<KnockoutBracket
  rounds={rounds}
  thirdPlace={{
    id: "tp",
    date: "Sat 24 Oct",
    time: "13:00",
    home: { team: null, label: "SF 1 loser", score: null },
    away: { team: null, label: "SF 2 loser", score: null },
  }}
/>