Finals Board
betaFinals day on one board: two semi-finals feed the cup final and the bronze final, each pitch's matches sit on a shared timeline, and a hold moves the later matches on the final's pitch.
Preview
before any result: choose a team to record its win, and ⌘Z takes it back for 8 seconds
Champion to be decided
both semi-finals in, and Pitch 1 held: the cup final moves 15 minutes
Champion to be decided
played out, with the champion in place on load
Champion: Rovers
no bronze final, no kits so each team shows its initials, 70-minute slots, and a 10-minute hold
Champion to be decided
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/finals-board
Use it for
- Running a cup's finals day: record each winner as it happens, and hold the final's pitch when it runs late.
- Pass the four sides as the semi-finals pair them, and where and when each match is played. The slot and the hold's length come from the competition.
- Leave out schedule.bronze when there is no bronze final.
- Give each team its kit colors. A team without them shows its initials.
- Pass value and onChange to keep results in your data, or defaultValue to let the board keep its own. The same goes for held, onHold, and defaultHeld.
Avoid it for
- A draw bigger than four. Use KnockoutBracket, which takes any number of rounds.
- Showing a past finals day. Nothing here should be pressed then: use KnockoutBracket or MatchCard.
- A league day. Nothing feeds anything, so there is no bracket to fill in.
Props
- semis
The four sides, as the semi-finals pair them. Each is { id, name, colors?, pattern? }.
[[FinalsTeam, FinalsTeam], [FinalsTeam, FinalsTeam]]
- schedule
Each match's { pitch, kickoff, title? }, with kickoff like "13:00". Without bronze, there is no bronze final.
{ sf1, sf2, final, bronze? }
- minutes
How long each match holds its pitch: the competition's slot.
number · default 40
- value
Winners by match, as team ids, controlled. Like { sf1: "rovers", final: null }.
FinalsResults
- defaultValue
Winners at the start, when the board keeps its own.
FinalsResults
- onChange
Hears every result, and the old results back on ⌘Z.
(results: FinalsResults) => void
- held
Whether the final's pitch is held now, controlled.
boolean
- defaultHeld
Whether it starts held, when the board keeps its own.
boolean · default false
- onHold
Hears the hold switch.
(held: boolean) => void
- holdMinutes
How far a hold moves the matches on the final's pitch that have no result when it starts. A moved match keeps its new kickoff once recorded.
number · default 15
- className
Extra classes. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| semis | [[FinalsTeam, FinalsTeam], [FinalsTeam, FinalsTeam]] | - | The four sides, as the semi-finals pair them. Each is { id, name, colors?, pattern? }. |
| schedule | { sf1, sf2, final, bronze? } | - | Each match's { pitch, kickoff, title? }, with kickoff like "13:00". Without bronze, there is no bronze final. |
| minutes | number | 40 | How long each match holds its pitch: the competition's slot. |
| value | FinalsResults | - | Winners by match, as team ids, controlled. Like { sf1: "rovers", final: null }. |
| defaultValue | FinalsResults | - | Winners at the start, when the board keeps its own. |
| onChange | (results: FinalsResults) => void | - | Hears every result, and the old results back on ⌘Z. |
| held | boolean | - | Whether the final's pitch is held now, controlled. |
| defaultHeld | boolean | false | Whether it starts held, when the board keeps its own. |
| onHold | (held: boolean) => void | - | Hears the hold switch. |
| holdMinutes | number | 15 | How far a hold moves the matches on the final's pitch that have no result when it starts. A moved match keeps its new kickoff once recorded. |
| className | string | - | Extra classes. Later classes win. |
Accessibility
- Cup bracket and Day control are groups named by their titles.
- Each match's two teams are a radio group, named by the match and described by its pitch and kickoff. Tab reaches the group, the arrow keys move between the teams, and the chosen team is the winner.
- A recorded match also names its winner in text, like "Rovers won.", which the group's description reads with its pitch and kickoff.
- A final waits as a plain group that names who it waits for, like "Semi-final 1 winner", until both semi-finals are in.
- One live line says each result and each hold once, like "Semi-final 1: Rovers won. Cup final and Bronze final cleared." ⌘Z says the undo in the page's own region.
- ⌘Z works with focus on a team, since the teams are not text fields.
- A held match's new kickoff has "+15 min" beside it, in words as well as amber, so it shows in forced colors.
- The timeline is a named region with a list per pitch. It takes a Tab stop only when it scrolls sideways.
- The champion types itself in only when the final is recorded on the page, never on load. Screen readers hear the name whole. Under reduced motion or with motion off, it shows at once.
Examples
Four teams on two pitches, with a bronze final
<FinalsBoard
semis={[[rovers, lions], [hawks, wolves]]}
schedule={{
sf1: { pitch: "Pitch 1", kickoff: "13:00" },
sf2: { pitch: "Pitch 2", kickoff: "13:00" },
final: { pitch: "Pitch 1", kickoff: "14:30" },
bronze: { pitch: "Pitch 2", kickoff: "14:00" },
}}
/>Results in your data
const [results, setResults] = useState<FinalsResults>({});
const [held, setHeld] = useState(false);
<FinalsBoard semis={semis} schedule={schedule} value={results} onChange={setResults} held={held} onHold={setHeld} />