Match Score Grid
A tennis scoreboard drawn as a chart — one row per set, one cell per game marked with who served and who won it, breaks emphasised, tiebreaks tagged with their mini-score, and hold percentages counted from the same games.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-tennis-score-grid.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTennisScoreGrid" component — a tennis
scoreboard rendered as a chart — with zod for the contract. No chart library:
it is one HTML table plus two-band cell glyphs.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
sets: { label?: string; games: { server: "a" | "b"; winner: "a" | "b";
tiebreak?: boolean;
tiebreakScore?: { a: number; b: number } }[] }[];
meta: { players: { a: { name, seed? }, b: { name, seed? } };
context?: string;
outcome?: { type: "completed" } | { type: "live" }
| { type: "retired"; by: "a" | "b" } } }.
- Players are two fixed slots, not free-form ids, so a game can never point at
somebody who is not in the match.
- There is deliberately NO set-score field. Set scores, the headline scoreline,
the running score in every readout and the hold percentages are all counted
from `games` — the array the cells are drawn from — so no summary can drift
away from the marks above it.
- Component props = z.infer of the schema plus title?, cellHeight? (clamped
24–48), onRetry?, emptyState? and className. forwardRef, rest props spread on
the root.
Behavior
- Four first-class branches in one bg-card panel: loading (skeleton that keeps
the row-per-set silhouette), empty (a dashed strip of game cells plus one line
explaining how to read a cell), error (message + a Try again button only when
onRetry exists), ready.
- Derived, never fed: a game is a HOLD when server === winner and a BREAK
otherwise; a tiebreak is neither, because both players serve inside one, so it
is excluded from the hold/break totals and said so under the chart.
- Set completeness comes from one predicate — six games with two clear, 7-5, or
7-6 through a tiebreak, plus a deciding match tiebreak that is a set of one
game. A set that fails it is drawn short and its row is tagged "in play",
"ret." or "unfinished" according to meta.outcome.
- Tiebreaks: the set score prints the LOSER's points in the bracket (7-6(4) is a
7-4 tiebreak, 7-6(8) a 10-8 one). A tiebreakScore whose leader is not the
game's winner is dropped and reported under the chart rather than drawn —
`winner` is what everything else was counted from.
- Match result: completed → more sets wins; retired → the player who did not
stop goes through, even though the last set is unfinished; live → nobody has
won, the headline says who leads (sets first, then games in the set on court —
never cumulative match games, which is not a tennis notion of leading and can
contradict the decider row above), and "Level on court" when both are level.
- Interaction: the grid is one table with a single tab stop. Left/Right walk the
MATCH chronologically and step across set boundaries (rows are only where the
sequence wraps); Up/Down compare sets at the same game number, skipping sets
with no games; Home/End jump to the first/last game of the match. Hover or
focus writes one sentence into a readout line under the grid; the same
sentence is the cell's aria-label and its title.
- No timers, observers or animation frames anywhere, so there is nothing to
clean up on unmount.
Rendering & styling
- One cell = two stacked bands: slot "a" on top, slot "b" underneath, in every
cell and in the legend. The winner's band is the mark, and one slot value
places it, colours it (var(--chart-1) for a, var(--chart-2) for b) and names
it in the readout — a mark can never sit over the wrong player.
- Emphasis is the second channel: a hold outlines the winner's band (quiet — the
expected outcome), a break fills it solid (loud — what an analyst scans for),
a tiebreak fills it solid and puts "TB" in the other band with no serve tick.
- The serve tick is a 3px full-strength bar on the left of the server's band. It
can never land on a solid fill: a hold's own band is only outlined and a
break's tick sits in the loser's empty band, so it always reads against
--card in both themes.
- Semantic tokens only: bg-card, border, text-muted-foreground, text-destructive,
ring, plus var(--chart-1)/var(--chart-2). No hex, no low-alpha fills that
disappear on a near-black card.
- Layout: table-fixed with a fixed set-label column, a fixed score column and
the game columns sharing the rest; a min-width of
labels + games × 22px puts a scrollbar on the wrapper instead of ever
clipping a cell. Numbers are tabular-nums; cn() merges className.
- Accessibility: a real table with a sr-only caption stating the cell grammar,
<th scope="row"> per set, one sentence per cell as its accessible name, a
second small table for the per-player totals, focus-visible outlines and a
sr-only summary paragraph. Only the loading skeleton animates, and it stops
under prefers-reduced-motion.
Customization levers
- Density: cellHeight (24–48) drives the whole glyph; the 22px minimum game
column is the one number to raise if you want wider cells before scrolling.
- Format: swap the set-completeness predicate for Fast4, pro sets to eight or a
10-point deciding tiebreak — every "unfinished" tag on the card comes through
that one function.
- Emphasis: flip the pair (outline = break, solid = hold) if your audience
scans for holds instead; or drop the outline and use a corner wedge.
- Blocks: the legend, the tiebreak line, the per-player totals table and the
readout are independent — a compact live-score card can keep only the grid
and the scoreline.
- Palette: the two players read var(--chart-1) / var(--chart-2); re-map to a
club/away pair, or key them off a team token, in one place.
- Counting: hold percentage excludes tiebreaks by design. If your feed counts
them (some do), fold them into serviceGames in the ingest pass and update the
sentence under the legend so the card still explains its own denominator.Concepts
- One value places, colours and names the mark — the slot that won a game picks which half of the cell is marked, which chart token fills it and which name the readout speaks. A mark coloured by one field and positioned by another is how scoreboards end up crediting the wrong player.
- Counted, never carried — the contract has no set-score field. Set scores, the scoreline, the running score in each cell's sentence and both hold percentages come out of the same pass over
games, so the summary column cannot contradict the cells above it. - Hold quiet, break loud — the expected outcome is an outline, the exception is a solid fill, and the serve tick always sits on an unfilled band. Emphasis carries the thing an analyst is scanning for without a second colour scale.
- A tiebreak is neither a hold nor a break — both players serve inside one, so it is kept out of the service totals, marked "TB" instead of with a tick, and its mini-score is printed the way tennis prints it: the loser's points in the bracket.
- Unfinished is a first-class shape — a set in play is simply a shorter array, and the completeness predicate (not the payload) decides whether a row gets tagged. That is also what lets a retirement have a winner while its last set has none.
- The match is one sequence, the rows are where it wraps — left/right arrows step across a set boundary into the next set's first game, while up/down compare the same game number between sets.
Return Depth
A four-state return-of-serve chart — returns grouped into depth bands laid out to real court metres, one bar per serve answered, the points-won rate overlaid on a second axis, and a verdict on whether depth converts.
Point Momentum Run
A four-state point-by-point momentum strip — one tick per point above or below the centre line, consecutive points by the same player merged into run blocks, break points and breaks of serve flagged, set dividers drawn and the longest run per player printed.