Knockout Bracket
betaA 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
sixteen sides: three rounds show, and the chevrons page on to the final
- RoversSaints
- LionsHawks
- WolvesFalcons
- BearsTigers
- Rovers CitySaints City
- Lions CityHawks City
- Wolves UnitedFalcons United
- Bears UnitedTigers United
- R16 1 winnerR16 2 winner
- R16 3 winnerR16 4 winner
- R16 5 winnerR16 6 winner
- R16 7 winnerR16 8 winner
- QF 1 winnerQF 2 winner
- QF 3 winnerQF 4 winner
- SF 1 winnerSF 2 winner
starts on the semi-finals: initialRound={1}
- Rovers31Tigers12 Rovers won.
- Bears14Lions14 Lions won.
- Hawks20Saints20 Hawks won 4–3 on kicks.
- Falcons9Wolves22 Wolves won.
- Rovers24Lions17 Rovers won.
- Hawks7Wolves3
- RoversSF 2 winner
a narrow column shows one round
- Rovers31Tigers12 Rovers won.
- Bears14Lions14 Lions won.
- Hawks20Saints20 Hawks won 4–3 on kicks.
- Falcons9Wolves22 Wolves won.
- Rovers24Lions17 Rovers won.
- Hawks7Wolves3
- RoversSF 2 winner
no rounds yet
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| rounds | BracketRound[] | - | Widest round first. Each is { name, matches }, and each match is BracketMatchData. |
| initialRound | number | 0 | The index of the leftmost round on mount. It pages on to the final, no further. |
| thirdPlace | BracketMatchData | - | A third-place play-off, drawn under the final. |
| thirdPlaceLabel | string | "Third-place play-off" | The title over the third-place match. |
| selectedId | string | - | Controlled: the chosen match's id. |
| defaultSelectedId | string | - | The match chosen first, when you don't control it. It also makes each match a button. |
| onSelect | (match: BracketMatchData) => void | - | Makes each match a button, and hears the one chosen. |
| className | string | - | Extra classes for the root. Later classes win. |
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 },
}}
/>