PitchKit

Quickstart

Build a real chart from real data — the move that produced England's goal in the Euro 2024 final.

By the end of this page you'll have plotted a real passage of play from a real match: the move that produced England's goal in the Euro 2024 final — Pickford's throw out, Saka driving a third of the pitch up the right, the cutback, and Cole Palmer's finish.

Nothing here is hardcoded. The match arrives over the network, the move is found by filtering it, and the chart is a stack of PitchKit layers over the result.

Fetching Spain 2–1 England from StatsBomb open data (~3 MB)…

"use client";import { useEffect, useState } from "react";import { Annotate, Arrows, Comet, Pitch, Scatter } from "@pitchkit/react";import {  fetchMatchEvents,  isCarry,  isGoal,  isPass,  shots,} from "@pitchkit/data-providers/statsbomb";import type {  StatsBombCarry,  StatsBombEvent,  StatsBombPass,  StatsBombShot,} from "@pitchkit/data-providers/statsbomb";import { docsAppearance } from "./docs-appearance";/** Euro 2024 final — Spain 2–1 England, Berlin, 14 July 2024. */const EURO_2024_FINAL = 3943043;interface Chain {  passes: StatsBombPass[];  carries: StatsBombCarry[];  goal: StatsBombShot;}/** * The possession that produced England's goal, cut off at the goal itself. * * StatsBomb stamps every event with a `possession` number and the team that * owned it, so the move is a filter rather than a reconstruction. Two * details do the real work: * * - A possession does **not** end at the shot — it runs on until the ball *   changes hands, so it has to be truncated at the goal itself. * - A possession contains the *other* team's events too (pressures, blocks, *   an interception that didn't stick), so it's filtered down to the team *   that owned it. */function goalChain(events: StatsBombEvent[]): Chain | undefined {  const goal = shots(events)    .filter(isGoal)    .find((shot) => shot.team.name === "England");  if (!goal) return undefined;  const possession = events.filter((event) => event.possession === goal.possession);  const upToGoal = possession.slice(0, possession.indexOf(goal) + 1);  const attacking = upToGoal.filter((event) => event.team.id === event.possession_team.id);  return {    passes: attacking.filter(isPass),    // A unit or two is a touch adjustment, not progression — and it    // renders as a speck rather than a trail. (StatsBomb x/y are abstract    // units on a 120 x 80 grid, not metres.)    carries: attacking      .filter(isCarry)      .filter((carry) => Math.hypot(carry.endX - carry.x, carry.endY - carry.y) > 2),    goal,  };}/** * The quickstart's finished chart: load a real match, isolate the move that * produced a goal, and plot it — passes as arrows, carries as comet trails, * the goal as a labelled marker. */export function QuickstartChainBasic() {  const [chain, setChain] = useState<Chain | undefined>();  const [failed, setFailed] = useState(false);  useEffect(() => {    fetchMatchEvents(EURO_2024_FINAL)      .then((events) => setChain(goalChain(events)))      .catch(() => setFailed(true));  }, []);  return (    <div>      <p className="mb-3 text-xs text-fd-muted-foreground">        {failed          ? "Couldn't reach StatsBomb open data."          : chain === undefined            ? "Fetching Spain 2–1 England from StatsBomb open data (~3 MB)…"            : `${chain.passes.length} passes · ${chain.carries.length} ${chain.carries.length === 1 ? "carry" : "carries"} · ${chain.goal.player?.name ?? "Unknown"}, ${chain.goal.minute}'`}      </p>      <Pitch type="statsbomb" appearance={docsAppearance}>        <Comet          data={chain?.carries ?? []}          x={(carry) => carry.x}          y={(carry) => carry.y}          x2={(carry) => carry.endX}          y2={(carry) => carry.endY}          gradient          endWidth={5}          tooltip={(carry) => `${carry.player?.name ?? "Unknown"} — carry`}        />        <Arrows          data={chain?.passes ?? []}          x={(pass) => pass.x}          y={(pass) => pass.y}          x2={(pass) => pass.endX}          y2={(pass) => pass.endY}          strokeWidth={2}          strokeOpacity={0.85}          tooltip={(pass) => `${pass.player?.name ?? "Unknown"} — pass`}        />        <Scatter          data={chain?.passes ?? []}          x={(pass) => pass.x}          y={(pass) => pass.y}          r={3}          stroke="white"          strokeWidth={1.5}          tooltip={(pass) => pass.player?.name ?? "Unknown"}        />        <Scatter          data={chain ? [chain.goal] : []}          x={(goal) => goal.x}          y={(goal) => goal.y}          r={6}          fill="var(--pitch-marker-goal)"          stroke="white"          strokeWidth={2}          tooltip={(goal) =>            `${goal.player?.name ?? "Unknown"} — goal, ${goal.shot.statsbomb_xg.toFixed(2)} xG`          }        />        <Annotate          data={chain ? [chain.goal] : []}          x={(goal) => goal.x}          y={(goal) => goal.y}          label={(goal) => `Goal · ${goal.shot.statsbomb_xg.toFixed(2)} xG`}          offsetY={-12}        />      </Pitch>    </div>  );}

Install

react has the components you render, core has the coordinate transforms underneath them, and data-providers is a convenience library for handling data.

npm install @pitchkit/core @pitchkit/react @pitchkit/data-providers

Step 1 — load a match

The Euro 2024 final is 3943043; every fixture is discoverable through fetchCompetitions and fetchMatches (see Events).

import { fetchMatchEvents } from "@pitchkit/data-providers/statsbomb";

const events = await fetchMatchEvents(3943043); // Spain 2–1 England

events is ~3,500 StatsBomb events in StatsBomb's own shape — nothing is renamed. The one addition is that location arrays are lifted to x / y (and end_location to endX / endY), so an accessor can read them directly.

A match file is roughly 3 MB. Fetch it once and cache it — don't call fetchMatchEvents inside a render.

Step 2 — draw a pitch

<Pitch> owns the coordinate system. Tell it which provider's grid your numbers are on and it does the rest — StatsBomb's is 120 × 80, so nothing needs normalising. It's responsive by default: it fills its container and keeps the pitch's aspect ratio. Passing explicit width/height is available but not necessary.

import { Pitch } from "@pitchkit/react";

<Pitch type="statsbomb" />;

Step 3 — find the move

This is the part that's actually football rather than plumbing.

Start with the goal. shots() narrows the feed to shots, isGoal is an ordinary predicate, so finding it is one chain of array methods:

import { isCarry, isGoal, isPass, shots } from "@pitchkit/data-providers/statsbomb";

const goal = shots(events)
  .filter(isGoal)
  .find((shot) => shot.team.name === "England");
if (!goal) return;

Now work backwards. StatsBomb stamps every event with a possession number and the team that owned it, so "the move that led to this goal" is a filter:

const possession = events.filter((event) => event.possession === goal.possession);
const upToGoal = possession.slice(0, possession.indexOf(goal) + 1);
const attacking = upToGoal.filter((event) => event.team.id === event.possession_team.id);

const passes = attacking.filter(isPass);
const carries = attacking.filter(isCarry);

Two details there are easy to get wrong:

  • A possession doesn't end at the shot. It runs on until the ball genuinely changes hands. A goal is the tidy case — this possession ends on Palmer's finish, so the slice costs nothing. Point the same code at a blocked shot or a save and the possession carries on through the rebound, and without the slice you'd draw all of it.
  • A possession contains the other team's events. Pressures, blocks, an interception that didn't stick are all stamped with the same possession number. Comparing team against possession_team keeps the chain to the side building it.

Step 4 — plot the move

Layers stack inside the <Pitch>, in paint order. Each one takes plain data plus accessor functions:

import { Arrows, Comet, Pitch, Scatter } from "@pitchkit/react";

<Pitch type="statsbomb">
  <Comet
    data={carries}
    x={(carry) => carry.x}
    y={(carry) => carry.y}
    x2={(carry) => carry.endX}
    y2={(carry) => carry.endY}
    gradient
  />
  <Arrows
    data={passes}
    x={(pass) => pass.x}
    y={(pass) => pass.y}
    x2={(pass) => pass.endX}
    y2={(pass) => pass.endY}
  />
  <Scatter
    data={[goal]}
    x={(g) => g.x}
    y={(g) => g.y}
    r={6}
    tooltip={(g) => `${g.player?.name} — goal, ${g.shot.statsbomb_xg.toFixed(2)} xG`}
  />
</Pitch>;

Saka's carry up the right is the one comet on the chart, and it's why carries are worth drawing separately: as an arrow it would be indistinguishable from the long pass that started the move.

Most props accept a static value or a per-datum function, so styling is data-driven without a separate encoding step:

<Scatter data={allShots} x={(s) => s.x} y={(s) => s.y} r={(s) => 3 + s.shot.statsbomb_xg * 9} />

The finished chart

That's the example at the top of this page: five passes and one carry, goalkeeper to goal. Pickford throws to Bellingham, Bellingham to Palmer, Palmer wide to Saka, Saka carries to the byline and cuts it back, Bellingham bounces it into Palmer's path, and Palmer finishes from the edge of the box at 0.04 xG.

Expand View Code on the preview for the complete component, including the loading states and the carry filter.

Where to go next

  • Coordinates & pitch types — what type="statsbomb" actually sets, and how to plot Opta, UEFA, SkillCorner or your own grid.
  • Overlays — every layer, one page each, with a live example.
  • Data — the rest of @pitchkit/data-providers: StatsBomb 360 tracking and SkillCorner.
  • Theming — the pitch is styled with CSS variables, so it inherits your app's look.
  • Build with AI — hand an agent the real API instead of letting it guess.

On this page