Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartTennisScoreGridData,
  ChartTennisScoreGridOutcome,
  ChartTennisScoreGridSet,
  ChartTennisScoreGridSlot,
} from "./chart-tennis-score-grid.contract"

export interface ChartTennisScoreGridProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-tennis-score-grid.json

Prompt

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.

On This Page