PitchKit
Charts

RaceChart

A cumulative step chart over match minutes — the chart usually called an xG race chart, xG timeline or xG flow chart.

00.511.520'15'30'45'60'75'90'HTSpainEngland1.520.52
"use client";import { RaceChart } from "@pitchkit/react";// Shots from one match, in the shape a provider gives them: a minute, a// team, an xG, and whether it went in. Nothing is pre-aggregated —// <RaceChart> does the accumulating.const shots = [  { minute: 11, team: "Spain", xg: 0.068, goal: false, period: 1 },  { minute: 12, team: "Spain", xg: 0.118, goal: false, period: 1 },  { minute: 16, team: "England", xg: 0.049, goal: false, period: 1 },  { minute: 27, team: "Spain", xg: 0.048, goal: false, period: 1 },  { minute: 42, team: "Spain", xg: 0.078, goal: false, period: 1 },  { minute: 45, team: "England", xg: 0.18, goal: false, period: 1 },  { minute: 46, team: "Spain", xg: 0.113, goal: true, period: 2 },  { minute: 48, team: "Spain", xg: 0.246, goal: false, period: 2 },  { minute: 55, team: "Spain", xg: 0.243, goal: false, period: 2 },  { minute: 63, team: "England", xg: 0.056, goal: false, period: 2 },  { minute: 66, team: "Spain", xg: 0.162, goal: false, period: 2 },  { minute: 70, team: "England", xg: 0.075, goal: false, period: 2 },  { minute: 72, team: "England", xg: 0.038, goal: true, period: 2 },  { minute: 81, team: "Spain", xg: 0.164, goal: false, period: 2 },  { minute: 86, team: "Spain", xg: 0.283, goal: true, period: 2 },  { minute: 89, team: "England", xg: 0.117, goal: false, period: 2 },];/** One step line per team: flat between shots, jumping at each one by its xG. */export function RaceChartBasic() {  return (    <RaceChart      series={[        { id: "Spain", data: shots.filter((s) => s.team === "Spain") },        { id: "England", data: shots.filter((s) => s.team === "England") },      ]}      time={(s) => s.minute}      value={(s) => s.xg}      emphasise={(s) => s.goal}      period={(s) => s.period}    />  );}

<RaceChart> accumulates a value against the clock and draws one step line per series. With xG per shot, that is the chart variously called an xG race chart, xG timeline or xG flow chart — they all describe this. It is named generically because the quantity need not be xG: point it at shot counts, expected threat, or anything else that adds up over a match.

It is a root, not a layer. There is no <Pitch> involved and no type prop — see Charts for why.

Usage

<RaceChart
  series={[
    { id: "Spain", data: spainShots },
    { id: "England", data: englandShots },
  ]}
  time={(s) => s.minute}
  value={(s) => s.xg}
  emphasise={(s) => s.goal}
  period={(s) => s.period}
/>

series carries the data; time and value are accessors like every other PitchKit prop. Nothing is pre-aggregated — the running total is computed for you, so you hand it the same flat list of shots a provider gives you.

Why it steps

A cumulative total is flat between events and jumps at each one. Anything that slopes between points would draw xG accruing during minutes when no shot was taken, which is not a thing that happens, so there is no curve option. This is what d3 calls curveStepAfter.

The line is anchored at kick-off and runs to full time rather than starting at the first shot and stopping at the last — a chart that begins at 11' and ends at 86' misreads as a shorter match.

Goals, and anything else worth marking

emphasise is an accessor like any other, so it can pick out whatever matters in your data. It draws the larger ringed marker on the events it returns true for:

<RaceChart ... emphasise={isGoal} />

It is not a goal flag — it is "mark this one". Some things it is good for:

Seriesemphasise
Cumulative xGisGoal — the canonical case
Cumulative xG(s) => s.xg > 0.3 to call out big chances
Cumulative shotsisOnTarget
Cumulative passesisKeyPass or isAssist
Cumulative xTthe action that started the move that scored
Any seriesone player's contributions inside a team line

Leave it off and no event is emphasised. appearance.markers decides how much else is drawn: "emphasis" (default) dots only the emphasised events, "all" dots every one, "none" dots nothing.

Goals take their team's colour rather than --pitch-marker-goal. On a pitch, "goal" is a property of a shot and a distinct colour reads well; here a goal already belongs to a team, so shape carries "goal" and colour carries "whose" — which also means the chart never encodes meaning by colour alone.

Half time comes from the data

Pass period and the period breaks are derived rather than assumed:

<RaceChart ... period={(s) => s.period} />

This matters more than it looks. Halves do not end on 45. Stoppage time is inside StatsBomb's own minute numbering, so the Euro 2024 final's first half runs to 47'. A rule drawn at a fixed 45 sits in the wrong place in most matches. Without a period accessor no rules are drawn at all, which is better than drawing one somewhere wrong.

The same accessor gives you the extra-time breaks free. The x-axis ends at max(90, ceil(latest)), so a knockout match that reaches 121' is not clipped and a quiet one still shows a full 90.

Reading a value at a minute

Hover or tap anywhere on the plot: a crosshair reports every series at that minute. That is the question a race chart exists to answer, and a per-mark tooltip cannot answer it — you would have to land on a line. Pass tooltip to replace the readout's body.

On a touch screen a horizontal drag scrubs the crosshair while a vertical one still scrolls the page.

Annotations

Events that accumulate nothing — bookings, substitutions, a red card — are not series. They are children, positioned through useRaceChart():

function Card({ minute, team }: { minute: number; team: string }) {
  const { scaleX, scaleY, valueAt } = useRaceChart();
  return (
    <rect x={scaleX(minute) - 3} y={scaleY(valueAt(team, minute)) - 9} width={6} height={8} />
  );
}

<RaceChart ...>
  <Card minute={52} team="England" />
</RaceChart>;

valueAt(seriesId, time) is what anchors the mark to a line rather than leaving it floating: a 52' booking sits at whatever that team's cumulative xG was at 52'. It uses the same step-after semantics as the line, so an event landing exactly on time is included.

00.20.40.60.810'15'30'45'60'75'90'SpainEngland0.920.46Kane booked, 24'Olmo booked, 29'Stones booked, 52'
"use client";import { RaceChart, useRaceChart } from "@pitchkit/react";const shots = [  { minute: 11, team: "Spain", xg: 0.068, goal: false },  { minute: 16, team: "England", xg: 0.049, goal: false },  { minute: 27, team: "Spain", xg: 0.048, goal: false },  { minute: 45, team: "England", xg: 0.18, goal: false },  { minute: 46, team: "Spain", xg: 0.113, goal: true },  { minute: 55, team: "Spain", xg: 0.243, goal: false },  { minute: 66, team: "Spain", xg: 0.162, goal: false },  { minute: 70, team: "England", xg: 0.075, goal: false },  { minute: 72, team: "England", xg: 0.038, goal: true },  { minute: 86, team: "Spain", xg: 0.283, goal: true },  { minute: 89, team: "England", xg: 0.117, goal: false },];// A booking adds nothing to either running total, so it is not a series.const bookings = [  { minute: 24, team: "England", player: "Kane" },  { minute: 29, team: "Spain", player: "Olmo" },  { minute: 52, team: "England", player: "Stones" },];/** * `valueAt(seriesId, minute)` is what puts each card *on* its team's line * rather than floating beside it: Stones' 52' yellow sits at whatever * England's cumulative xG was at 52'. */function Bookings() {  const { scaleX, scaleY, valueAt } = useRaceChart();  return (    <g>      {bookings.map((card) => (        <rect          key={`${card.team}-${card.minute}`}          x={scaleX(card.minute) - 3}          y={scaleY(valueAt(card.team, card.minute)) - 10}          width={6}          height={8}          rx={1}          style={{            fill: "var(--pitch-card-yellow, #facc15)",            // The surface ring keeps it legible where it crosses the line.            stroke: "var(--pitch-chart-surface)",            strokeWidth: 1.5,          }}        >          <title>{`${card.player} booked, ${card.minute}'`}</title>        </rect>      ))}    </g>  );}/** Anything that doesn't accumulate is a child, not a series. */export function RaceChartAnnotationsBasic() {  return (    <RaceChart      series={[        { id: "Spain", data: shots.filter((s) => s.team === "Spain") },        { id: "England", data: shots.filter((s) => s.team === "England") },      ]}      time={(s) => s.minute}      value={(s) => s.xg}      emphasise={(s) => s.goal}    >      <Bookings />    </RaceChart>  );}

Shading under the line

<RaceChart ... appearance={{ area: true }} />

Off by default. With two series the washes overlap exactly where the lines cross, which is the part of the chart worth reading. It works well for a single series:

00.511.520'15'30'45'60'75'90'1.58
"use client";import { RaceChart } from "@pitchkit/react";// One team's shots. The area reads cleanly here precisely because there// is a single series — with two, the washes overlap where the lines cross,// which is the part of the chart worth reading.const shots = [  { minute: 11, xg: 0.068, goal: false },  { minute: 12, xg: 0.118, goal: false },  { minute: 27, xg: 0.048, goal: false },  { minute: 35, xg: 0.027, goal: false },  { minute: 42, xg: 0.078, goal: false },  { minute: 46, xg: 0.113, goal: true },  { minute: 48, xg: 0.246, goal: false },  { minute: 55, xg: 0.243, goal: false },  { minute: 66, xg: 0.162, goal: false },  { minute: 69, xg: 0.033, goal: false },  { minute: 81, xg: 0.164, goal: false },  { minute: 86, xg: 0.283, goal: true },];/** `appearance.area` shades under each line at ~10% of the series hue. */export function RaceChartAreaBasic() {  return (    <RaceChart      series={[{ id: "Spain", label: "Spain", data: shots }]}      time={(s) => s.minute}      value={(s) => s.xg}      emphasise={(s) => s.goal}      appearance={{ area: true, markers: "all" }}    />  );}

Sizing

Responsive by default, like <Pitch> — it fills its container, so sizing one means sizing its parent. Passing width and height is the opt-out. Below 420px the box gets squarer, because a 2:1 chart on a phone leaves a plot barely taller than its own axis labels.

Theming

CSS variables only, as everywhere else.

VariableWhat it colours
--pitch-series-1 … -6The series lines, by position in the array
--pitch-axisBaseline and period rules
--pitch-gridGridlines
--pitch-chart-surfaceThe ring around each marker
--pitch-chart-textValues and labels
--pitch-chart-mutedTick labels

Each series' total is printed at its own line end, inside the plot, rather than in a right-hand gutter, so the lines use the full width of the chart. The leading series labels above its line and every other series below its own, so two teams finishing on close totals don't print one number on top of the other. Only the value is printed, since the legend already names the series. A label below its line clears the line's own earlier step rather than sitting on it, and the text carries a halo in the surface colour so it stays legible wherever it does cross a line. The axis ceiling leaves room above the highest total for the leader's label, so the top of the axis can sit a little higher than the nearest round number; pin maxValue and you get exactly the axis you asked for, with the label running into the padding above the plot. A trailing total too close to the baseline to fit a label below goes above its line instead.

For anything the variables don't reach, every element carries a data-pitchkit-part, so a Tailwind arbitrary variant can select it without owning the JSX: race-line, race-area, race-marker, race-emphasis, race-end-label, race-axis, race-grid, race-label, race-period, race-legend, race-crosshair. The root carries data-pitchkit-layer="race" and each series group a data-pitchkit-series with its id.

Colour follows the entity, not its rank: a series takes its slot from its position in the series array, so removing one never repaints the others. A series given a className and no color drops its themed default, the same rule as every mark layer — the class goes on the series group, so one utility reaches the line, the area and the markers.

Live data

The same chart against a real Euro 2024 match, fetched in the browser.

Fetching the match from StatsBomb open data (~3 MB)…

00.20.40.60.810'15'30'45'60'75'90'
"use client";import { useEffect, useState } from "react";import { RaceChart } from "@pitchkit/react";import { fetchMatchEvents, isGoal, shots } from "@pitchkit/data-providers/statsbomb";import type { StatsBombShot } from "@pitchkit/data-providers/statsbomb";import { DEFAULT_MATCH_ID, controlClass, matchLabel, useEuroMatches } from "./statsbomb-live";/** * An xG race from a real Euro 2024 match, fetched in the browser. * * One line does the work that matters: * *     shots(events).filter((s) => s.period <= 4) * * That filter is not optional. StatsBomb's period 5 is the penalty * shootout, and shootout penalties carry xG like any other shot — in the * England–Switzerland quarter-final they add 7.05 xG on top of 1.74 from * the match itself. Without the filter, every knockout tie that goes to * penalties draws a chart several times too tall. */export function RaceChartStatsbombBasic() {  const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID);  const [result, setResult] = useState<{ key: number; shots: StatsBombShot[] } | undefined>();  const [failed, setFailed] = useState(false);  const matches = useEuroMatches();  const loaded = result?.key === matchId ? result.shots : undefined;  useEffect(() => {    fetchMatchEvents(matchId)      .then((events) =>        setResult({ key: matchId, shots: shots(events).filter((s) => s.period <= 4) }),      )      .catch(() => setFailed(true));  }, [matchId]);  // Home team first, so the two lines keep their colours as you change match.  const match = matches.find((m) => m.match_id === matchId);  const teams = match    ? [match.home_team.home_team_name, match.away_team.away_team_name]    : [...new Set((loaded ?? []).map((s) => s.team.name))];  return (    <div>      <select        aria-label="Euro 2024 match"        value={matchId}        disabled={matches.length === 0}        onChange={(event) => {          setFailed(false);          setMatchId(Number(event.target.value));        }}        className={`w-full min-w-0 pl-2 pr-8 sm:w-auto sm:max-w-xs ${controlClass}`}      >        {matches.length === 0 && <option value={DEFAULT_MATCH_ID}>Loading matches…</option>}        {matches.map((m) => (          <option key={m.match_id} value={m.match_id}>            {matchLabel(m)}          </option>        ))}      </select>      <p className="my-3 text-xs text-fd-muted-foreground">        {failed          ? "Couldn't reach StatsBomb open data."          : loaded === undefined            ? "Fetching the match from StatsBomb open data (~3 MB)…"            : `${loaded.length} shots · ${loaded.filter(isGoal).length} goals`}      </p>      <RaceChart        series={teams.map((team) => ({          id: team,          data: (loaded ?? []).filter((s) => s.team.name === team),        }))}        time={(s) => s.minute + s.second / 60}        value={(s) => s.shot.statsbomb_xg}        emphasise={isGoal}        period={(s) => s.period}        tooltip={(rows, minute) => (          <>            <div className="font-semibold">{`${Math.round(minute)}'`}</div>            {rows.map((row) => (              <div key={row.id} className="flex items-center gap-1.5">                <span                  className="h-0.5 w-2.5 shrink-0 rounded-full"                  style={{ background: row.color }}                />                <span className="opacity-75">{row.label}</span>                <span className="ml-auto font-semibold tabular-nums">{row.value.toFixed(2)}</span>              </div>            ))}          </>        )}      />    </div>  );}

Note the period <= 4 filter in the source. StatsBomb's period 5 is the penalty shootout, and shootout penalties carry xG like any other shot — in the England–Switzerland quarter-final they add 7.05 xG on top of the 1.74 from the match itself. Without that filter every tie that goes to penalties draws a chart several times too tall.

On this page