Charts

Bracket Replay

A four-state playable knockout bracket — a 16-entrant draw revealed round by round with play/pause, a round scrubber, seed-aware upset chips, structural feed repair and a champion cell that fills at the end.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartBracketReplayData,
  ChartBracketReplayEntrant,
  ChartBracketReplayRound,
} from "./chart-bracket-replay.contract"

export interface ChartBracketReplayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartBracketReplayData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-bracket-replay.json

Prompt

Build a React + TypeScript + Tailwind "ChartBracketReplay" card — a knockout
draw (racket-sport bracket) replayed round by round, drawn as absolutely
positioned match cards with hand-rolled SVG elbow connectors (no chart
library) and zod for the contract.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title?: string;
    rounds: { label ("R16" | "QF" | "SF" | "F" …),
              matches: { a: { name, seed? }, b: { name, seed? },
                         winner: "a" | "b", score? }[] }[] }
  with structural refines: the first round holds at most 8 matches
  (16 entrants — readability cap), every later round holds exactly half the
  previous round's matches, and the last round is a single final so "the
  champion" is well defined. Component props = z.infer of that schema plus
  onRetry?, emptyState? and className; no parallel hand-written interface.
- Name continuity between rounds is deliberately NOT a refine: an
  inconsistent feed must still be drawable so the inconsistency can be shown.

Behavior
- Four first-class branches inside one bg-card panel: loading (a skeleton of
  shrinking card columns keeping the bracket silhouette, aria-hidden, plus an
  sr-only role=status), empty ("draw not released" over a bare-bracket
  glyph), error (destructive copy + "Try again" only when onRetry exists),
  and ready.
- Draw maths live in one exported pure function. It walks the rounds
  BACKWARD validating that every participant equals the recorded winner of
  the feeder match below; on mismatch it trusts the LATER round, relabels the
  earlier winner (name and seed, cascading toward round one) and returns one
  repair sentence per fix — the card prints them under the bracket, never a
  contradictory line silently. It also flags upsets: winner seed number
  strictly greater than loser seed, both seeded.
- One timeline position = revealed round count (a float, so matches inside
  the round being revealed stagger ~90 ms apart as a sub-position). Rounds at
  or below the position show winners; rounds above it show pairings only.
- NO autoplay: ready parks fully revealed with the champion visible — a
  deterministic first paint with no effects. Play from the end rewinds to
  round 0 (bare first-round pairings, no winners) and replays forward; Play
  mid-timeline resumes, Pause holds.
- Playback is requestAnimationFrame with timestamp deltas — never
  setInterval — cancelled on pause, speed change, data swap and unmount.
  Speed toggle 0.5× / 1× / 2× scales the deltas.
- prefers-reduced-motion (matchMedia, listener cleaned up): whole rounds
  reveal at once on a fixed cadence, no stagger and no tweens
  (motion-reduce:transition-none on every transition).
- A polite live region says one sentence per round boundary ("QF decided."),
  and at the end names the champion — throttled by construction.
- Controls: Play/Pause button with aria-pressed and a state-aware aria-label,
  a labelled native range scrubber over whole rounds (seek anytime, seeking
  pauses; aria-valuetext names the round) with an aria-hidden label rail
  underneath, and a role=radiogroup speed toggle.
- Hover or focus on a match card shows a tooltip (full names, seeds, score,
  upset flag, or "not yet revealed"); the cards form one roving-tabindex list
  — a single tab stop, ArrowUp/Down walking column by column, ArrowRight
  jumping to the match this one feeds, ArrowLeft to the feeder below,
  Home/End to the ends. Pointer-leave hands the tooltip back to the focused
  card. An sr-only table repeats every result in full, independent of how far
  the reveal has been scrubbed.

Rendering & styling
- Fixed pixel geometry: one column per round plus a champion cell, match
  cards 168×72 on a pitch that doubles per round so every match sits centred
  between the two that feed it; nothing is measured from the DOM, so SSR
  paints the finished picture. The stage pans horizontally inside the card
  (min-w-0 + overflow-x-auto) and never blows out the page.
- SVG elbow connectors in border ink between rounds (M → H mid → V → H); a
  connector strengthens (muted-foreground stroke, heavier width) once its
  feeder match is decided, and the final's line to the champion cell does the
  same.
- Decided matches: winner name font-semibold behind a 2px side bar in
  var(--chart-1), loser muted, score in muted tabular text; upsets wear a
  small chip tinted by color-mix of var(--chart-4) with the card. Undecided
  matches print both names in plain foreground with the score line reserved
  (opacity 0) so nothing jumps when it lands.
- The champion cell sits dashed and empty ("to be revealed") until the final
  is decided, then fills with the champion's name and a 2px ring drawn as a
  box-shadow in var(--chart-1).
- Readouts: the current round label as the big figure, "round r / R" and
  "n upsets so far" beside it; column headers brighten as their round
  decides.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
  text-destructive, bg-popover, accent-primary on the range input,
  var(--chart-1) and var(--chart-4) — never raw hex/rgb/oklch. cn() merges
  className; remaining props spread on the root; forwardRef to the panel.

Customization levers
- Density: CARD_W / CARD_H / COL_GAP / ROW_GAP set the whole geometry from
  four constants — shrink them for an embeddable mini-bracket, or drop the
  score line and keep results in the tooltip only.
- Pace: STAGGER_MS (per match), HOLD_MS (pause on a decided round) and the
  SPEEDS array tune the replay; keep the reduced-motion whole-round path.
- Palette: the winner bar and champion ring read var(--chart-1), the upset
  chip var(--chart-4) — remap to any chart tokens, but keep text in text
  tokens and connectors in border ink.
- Vocabulary: round labels are the feed's own ("R16" or "Round of 16"); the
  live region and readouts inherit them. Works for any single-elimination
  racket sport — swap set strings for badminton or squash game scores.
- Repair policy: trusting the later round is one branch of the pure layout
  function; a consumer who trusts the earlier round instead flips which side
  is relabelled and keeps the visible report.

Concepts

  • Timeline as revealed rounds — the whole replay hangs on one number: how many rounds show their results. Scrub stops are whole rounds, playback interpolates between them so matches stagger in, and every integer position renders identically whether it was scrubbed to or played through — deterministic, resumable, and honest under reduced motion.
  • No autoplay, parked at the end — the ready card's first paint is the finished draw with the champion ringed, because a recap page must answer "who won" before it performs. Play is an explicit rewind-and-reveal, never something the card decides to do to a reader who just scrolled past.
  • Trust the later round — when a name arrives in round r that the round below never sent up, the feed contradicts itself and one side must win. The later round is closer to the result being displayed, so the earlier winner is relabelled — cascading back toward round one so no connector ever joins two cards that disagree — and every repair is printed under the bracket instead of being swallowed.
  • Seeded upset detection — an upset chip appears only when a seeded winner beats a better seed (winner's seed number strictly greater). An unseeded player beating a seed carries no seed number to invert, so it is narrated in the tooltip rather than flagged — the chip measures the seeding being wrong, not the result being surprising.
  • Connectors that decide with the match — the elbow from a match strengthens the moment that match reveals its winner, so the champion's path literally darkens across the board as the replay runs; before that, border-ink elbows only promise the structure.
  • Keyboard walks the draw, not the DOM — one tab stop, arrows up and down the columns, and left/right move along the connectors themselves: right jumps to the match this winner feeds, left drops to the feeder below, which is how a bracket is actually read.

On This Page