Charts

Score Ticker

A point-by-point tennis scoreboard replay — fed nothing but who won each rally, it derives games, deuce, tiebreaks and sets with real scoring rules, then plays the whole sequence back over a growing momentum strip with derived break, set and match points.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartScoreTickerConfig,
  ChartScoreTickerData,
  ChartScoreTickerPoint,
  ChartScoreTickerSlot,
} from "./chart-score-ticker.contract"

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

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartScoreTicker" component — a
point-by-point tennis scoreboard replay — with zod for the contract. No chart
library: it is one HTML scoreboard table plus a hand-rolled svg strip.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    players: { a: { name, seed? }, b: { name, seed? } };
    server: "a" | "b";                 // who serves the FIRST point
    config?: { bestOf?: 3 | 5;         // default 3
               tiebreakTo?: number;    // default 7, win by 2 (10 = match TB)
               finalSetTiebreak?: boolean };  // default true
    points: { winner: "a" | "b" }[];   // in played order — and NOTHING else
    context?: string }.
- The feed deliberately carries no scores, no game/set indices, no flags. The
  DERIVATION is the product: the component replays the points through real
  tennis rules — 0/15/30/40, deuce and advantage, game closure at four points
  two clear, the tiebreak at 6-6 played to `tiebreakTo` win-by-2 with the
  1-then-2 serve rotation, set closure at six games two clear or 7-6 via the
  tiebreak, an advantage decider when finalSetTiebreak is false, match closure
  at the bestOf majority. Service alternates by game and never resets between
  sets; whoever opens a tiebreak receives first in the next set.
- Component props = z.infer of the schema plus title?, stripHeight? (clamped
  64–160), beatMs? (ms per point at 1×, clamped 40–1000), onRetry?,
  emptyState? and className. forwardRef, rest props spread on the root.

Behavior
- Four first-class branches in one bg-card panel: loading (skeleton keeping
  the scoreline/board/strip/transport silhouette), empty (dashed strip and one
  line explaining that only point winners are needed), error (message + Try
  again only when onRetry exists), ready.
- ONE timeline index — points played, 0..n — is the sole source of truth.
  The scoreboard, the strip, the scrubber and the readout all render from the
  snapshot at that index; nothing else stores a position.
- No autoplay: the first ready paint is the FINAL score, parked at the end.
  Pressing Play there rewinds to 0 and rolls the match forward; pressing it
  mid-timeline continues from the scrub position; pressing again pauses.
- Playback is requestAnimationFrame with timestamp deltas against a beat of
  beatMs ÷ speed (1× / 2× / 4× as a radiogroup) — never setInterval — and the
  frame is cancelled on pause, on speed change and on unmount. A slow frame
  advances multiple points rather than stretching time.
- The scrubber is a labelled native range input over the point index whose
  aria-valuetext speaks the board ("Point 87 of 203 — 6-4 2-3, 30-40");
  touching it pauses the transport.
- Derived chances, never fed: before every point the engine asks whether one
  player is a single point from the game — a returner's game point is a BREAK
  point, a game point that would take the set is a SET point, one that would
  take the match is a MATCH point (at most one player can hold one, so a mark
  has exactly one owner).
- Tail guard, both directions: points arriving after the match is decided are
  ignored and reported out loud ("4 trailing points ignored — match decided");
  a feed that stops early parks the board mid-match honestly and says so.
- Hover or focus a strip tick for its tooltip sentence — point number, who
  served, who won, the exact score it left behind, and any chance riding on
  it; the strip is a single-tab-stop listbox whose arrow keys walk the played
  points (Home/End jump). The same sentence is the option's aria-label, its
  svg <title> and the visible readout line.
- Throttled aria-live: one polite status region announces game and set
  closures crossed during playback, pause (with the parked score) and the end
  of the replay — never rally by rally.
- prefers-reduced-motion: stepping is inherently discrete and stays; the
  decorative layer (the serving-dot pulse, hover transitions) is killed via
  motion-reduce: classes AND a matchMedia subscription (useSyncExternalStore,
  listener removed on unmount).

Rendering & styling
- Scoreboard: a real table — one column per completed/current set showing
  games per player with tiebreak points as superscripts (7-6(5) prints as 7⁷
  over 6⁵), a divided end column with the live game score (15/30/40, Ad at
  advantage, raw points inside a tiebreak), a serving dot beside whoever
  serves the next point, set winners bolded, sr-only caption stating the
  grammar.
- Strip: fixed 1000-unit viewBox stretched with preserveAspectRatio="none",
  axis-aligned rects only. One thin tick per played point — above the centre
  line in var(--chart-1) for player a, below in var(--chart-2) for b, with a
  ~2px gap clamped so a 250-point feed keeps visible ticks. The centre line
  spans the WHOLE feed so the unplayed remainder is visibly still to come.
  Chance marks hang in a lane under the strip, coloured by their owner and
  sized by stake: break point shortest, set point taller, match point tallest.
- Readout "point k / n" beside the scrubber; a legend keys both player colours
  and the three mark heights.
- Semantic tokens only: bg-card, border, text-muted-foreground,
  text-destructive, ring, accent-primary for the range input, plus
  var(--chart-1)/var(--chart-2). No hex, no rgb(), no oklch().
- cn() merges className; decorative nodes are aria-hidden; every control has
  focus-visible rings; a sr-only summary paragraph narrates the whole card.

Customization levers
- Format: config alone covers bo3/bo5, a 10-point match tiebreak and an
  advantage decider — no code changes. For Fast4 or pro sets, the set-closure
  predicate (six games, two clear) is the one function to swap.
- Pace: beatMs is the 1× beat; the SPEEDS tuple ([1, 2, 4]) is the radiogroup.
  A broadcast-style slow replay is beatMs 400; a highlights skim is 40.
- Density: stripHeight (64–160) scales the strip; the lane heights record
  what a break/set/match mark measures if you want a flatter lane.
- Blocks: the scoreboard table, the strip + readout, the transport row and
  the legend are independent slices — a compact widget can keep only the
  scoreboard and the transport.
- Palette: the two players read var(--chart-1)/var(--chart-2) from one
  SLOT_COLOR map; re-key it to team tokens in one place.
- Voice: every spoken surface funnels through three sentence builders
  (pointSentence, boardSentence, the announcer) — localise or re-tone the
  card there without touching geometry.

Concepts

  • The derivation is the product — the feed knows nothing but who won each point, and every figure on the card (games, deuce, the tiebreak bracket, the set columns, the winner) is computed by the same rules engine. A feed that carried its own scores could contradict its own points; this one cannot.
  • One index, three handles — points-played is the single source of truth, and the play button, the scrubber and the rAF loop are just three ways to move it. The scoreboard and the strip can never disagree about "when" because neither owns a clock.
  • Parked at the end, never autoplaying — the first paint is the final score, which is what a scoreboard is for; replay is something the reader asks for, and Play from the end means "take me back to 0-0 and roll it".
  • Chances are read off the standing score — a break point is the returner one point from the game, a set point is a game point that would take the set, a match point one that would take the match. At most one player can hold one at a time, so every mark under the strip has exactly one owner and one height.
  • The tail is guarded in both directions — points after the match is decided are counted and reported, not drawn (a decided match cannot score again), and a feed that stops early parks mid-match with the serving dot still out, saying so instead of pretending a result.
  • Announce closures, not rallies — the live region speaks when a game or set closes, when the transport pauses and when the replay ends. A 203-point match narrated point-by-point would be noise; the closures are the story.

On This Page