Pitch Palettes
Colour a pitch and its marks with Tailwind classes, using your own palette.
Every shot from the Euro 2024 final, loaded from StatsBomb open data. Switch palettes to see the same chart in each one.
Marker size is xG · solid markers are goals · data: StatsBomb
"use client";import { useEffect, useState } from "react";import { Pitch, Scatter } from "@pitchkit/react";import { fetchMatchEvents, isGoal, shots } from "@pitchkit/data-providers/statsbomb";import type { StatsBombShot } from "@pitchkit/data-providers/statsbomb";/** Euro 2024 final — Spain 2–1 England, Berlin, 14 July 2024. */const EURO_2024_FINAL = 3943043;interface TeamClasses { /** The team's name and score in the header. */ text: string; /** Shots that weren't goals. */ shot: string; /** Goals. */ goal: string;}interface Palette { name: string; card: string; muted: string; pitch: string; spain: TeamClasses; england: TeamClasses;}// Every colour is a Tailwind class. The palette colours themselves// (`newsprint-paper`, `dracula-pink`, …) are added to the theme in globals.css.const PALETTES: Palette[] = [ { name: "Newsprint", card: "bg-newsprint-paper text-newsprint-ink", muted: "text-newsprint-muted", pitch: "pitch-surface-newsprint-paper pitch-stripe-newsprint-stripe pitch-lines-newsprint-rule pitch-line-width-1", spain: { text: "text-newsprint-red", shot: "fill-newsprint-red/35 stroke-newsprint-red", goal: "fill-newsprint-red stroke-newsprint-paper", }, england: { text: "text-newsprint-navy", shot: "fill-newsprint-navy/35 stroke-newsprint-navy", goal: "fill-newsprint-navy stroke-newsprint-paper", }, }, { name: "Analyst navy", card: "bg-analyst-card text-analyst-text", muted: "text-analyst-muted", pitch: "pitch-surface-analyst-pitch pitch-stripe-analyst-stripe pitch-lines-analyst-line pitch-line-width-1", spain: { text: "text-analyst-orange", shot: "fill-analyst-orange/30 stroke-analyst-orange", goal: "fill-analyst-orange stroke-analyst-pitch", }, england: { text: "text-analyst-sky", shot: "fill-analyst-sky/30 stroke-analyst-sky", goal: "fill-analyst-sky stroke-analyst-pitch", }, }, { name: "Dracula", card: "bg-dracula-darker text-dracula-foreground", muted: "text-dracula-comment", pitch: "pitch-surface-dracula-background pitch-stripe-dracula-stripe pitch-lines-dracula-comment pitch-line-width-[1.5]", spain: { text: "text-dracula-pink", shot: "fill-dracula-pink/30 stroke-dracula-pink", goal: "fill-dracula-pink stroke-dracula-background", }, england: { text: "text-dracula-cyan", shot: "fill-dracula-cyan/30 stroke-dracula-cyan", goal: "fill-dracula-cyan stroke-dracula-background", }, }, { name: "Gruvbox", card: "bg-gruvbox-bg0-hard text-gruvbox-fg", muted: "text-gruvbox-fg4", pitch: "pitch-surface-gruvbox-bg pitch-stripe-gruvbox-bg0-soft pitch-lines-gruvbox-fg4 pitch-line-width-[1.5]", spain: { text: "text-gruvbox-red", shot: "fill-gruvbox-red/30 stroke-gruvbox-red", goal: "fill-gruvbox-red stroke-gruvbox-bg", }, england: { text: "text-gruvbox-yellow", shot: "fill-gruvbox-yellow/30 stroke-gruvbox-yellow", goal: "fill-gruvbox-yellow stroke-gruvbox-bg", }, },];/** Marker area grows with xG, so a 0.7 chance reads as roughly ten times a 0.07 one. */const radius = (shot: StatsBombShot) => 3 + Math.sqrt(shot.shot.statsbomb_xg) * 14;const tooltip = (shot: StatsBombShot) => `${shot.player?.name ?? "Unknown"}, ${shot.minute}' — ${shot.shot.statsbomb_xg.toFixed(2)} xG`;/** StatsBomb records every shot attacking left to right. Flip one team to face the other way. */const mirror = (shot: StatsBombShot): StatsBombShot => ({ ...shot, x: 120 - shot.x, y: 80 - shot.y,});function TeamShots({ data, classes }: { data: StatsBombShot[]; classes: TeamClasses }) { return ( <> <Scatter data={data.filter((s) => !isGoal(s))} x={(s) => s.x} y={(s) => s.y} r={radius} strokeWidth={1.25} className={classes.shot} tooltip={tooltip} /> <Scatter data={data.filter(isGoal)} x={(s) => s.x} y={(s) => s.y} r={radius} strokeWidth={1.5} className={classes.goal} tooltip={tooltip} /> </> );}function summary(data: StatsBombShot[]) { const xg = data.reduce((total, s) => total + s.shot.statsbomb_xg, 0); return { goals: data.filter(isGoal).length, detail: `${data.length} shots · ${xg.toFixed(2)} xG`, };}/** * Every shot from the Euro 2024 final, in four palettes. Spain attack right, * England left; marker size is xG and goals are solid. Switching palette * swaps class strings and nothing else. */export function StylingShotMapBasic() { const [all, setAll] = useState<StatsBombShot[]>([]); const [failed, setFailed] = useState(false); const [palette, setPalette] = useState(PALETTES[0]!); useEffect(() => { fetchMatchEvents(EURO_2024_FINAL) // Period 5 is the penalty shootout; there wasn't one, but it isn't open play either. .then((events) => setAll(shots(events).filter((s) => s.period <= 4))) .catch(() => setFailed(true)); }, []); const spain = all.filter((s) => s.team.name === "Spain"); const england = all.filter((s) => s.team.name === "England").map(mirror); const home = summary(spain); const away = summary(england); return ( <div> <div className="mb-3 flex flex-wrap gap-2"> {PALETTES.map((p) => ( <button key={p.name} type="button" aria-pressed={p === palette} onClick={() => setPalette(p)} className={`rounded-md border bg-fd-card px-3 py-1.5 text-sm ${p === palette ? "border-fd-primary text-fd-foreground" : "border-fd-border text-fd-muted-foreground"}`} > {p.name} </button> ))} </div> <div className={`rounded-xl p-4 ${palette.card}`}> <div className="mb-3 flex items-end justify-between gap-3"> <div> <div className={`text-lg font-bold ${palette.england.text}`}>England {away.goals}</div> <div className={`text-xs ${palette.muted}`}>{away.detail}</div> </div> <div className={`text-center text-xs ${palette.muted}`}> {failed ? "Couldn't reach StatsBomb open data." : all.length === 0 ? "Loading…" : "Euro 2024 final"} </div> <div className="text-right"> <div className={`text-lg font-bold ${palette.spain.text}`}>{home.goals} Spain</div> <div className={`text-xs ${palette.muted}`}>{home.detail}</div> </div> </div> <Pitch type="statsbomb" appearance={{ stripes: true, goalType: "box" }} className={palette.pitch} > <TeamShots data={england} classes={palette.england} /> <TeamShots data={spain} classes={palette.spain} /> </Pitch> <p className={`mt-2 text-xs ${palette.muted}`}> Marker size is xG · solid markers are goals · data: StatsBomb </p> </div> </div> );}The pitch and the marks are coloured entirely by Tailwind classes. Nothing on the chart passes a colour prop.
Setup
The examples use the pitch-* utilities from Tailwind.
Add those to your stylesheet first.
Adding a palette
Add the palette's colours to your theme. This is the Dracula block the example uses:
/* globals.css */
@theme {
--color-dracula-darker: #21222c;
--color-dracula-background: #282a36;
--color-dracula-stripe: #2f3242;
--color-dracula-comment: #6272a4;
--color-dracula-foreground: #f8f8f2;
--color-dracula-pink: #ff79c6;
--color-dracula-cyan: #8be9fd;
}Each colour is now available to every colour utility, including the pitch ones:
<Pitch
type="statsbomb"
className="pitch-surface-dracula-background pitch-stripe-dracula-stripe pitch-lines-dracula-comment"
/>The same names work for the card around the chart (bg-dracula-darker,
text-dracula-pink), so the header and the pitch share one palette.
The four palettes
The colours for every palette in the example:
/* globals.css */
@theme {
--color-newsprint-paper: #f4efe6;
--color-newsprint-stripe: #ede6da;
--color-newsprint-rule: #8a8074;
--color-newsprint-ink: #1f1f1f;
--color-newsprint-muted: #6b645b;
--color-newsprint-red: #c1121f;
--color-newsprint-navy: #1d3557;
--color-analyst-card: #0d1f2b;
--color-analyst-pitch: #122c3d;
--color-analyst-stripe: #163447;
--color-analyst-line: #cfcfcf;
--color-analyst-text: #e2e8f0;
--color-analyst-muted: #94a3b8;
--color-analyst-orange: #f97316;
--color-analyst-sky: #7dd3fc;
--color-dracula-darker: #21222c;
--color-dracula-background: #282a36;
--color-dracula-stripe: #2f3242;
--color-dracula-comment: #6272a4;
--color-dracula-foreground: #f8f8f2;
--color-dracula-pink: #ff79c6;
--color-dracula-cyan: #8be9fd;
--color-gruvbox-bg0-hard: #1d2021;
--color-gruvbox-bg: #282828;
--color-gruvbox-bg0-soft: #32302f;
--color-gruvbox-fg4: #a89984;
--color-gruvbox-fg: #ebdbb2;
--color-gruvbox-red: #fb4934;
--color-gruvbox-yellow: #fabd2f;
}Colouring marks
Pass fill-* and stroke-* classes through a mark's className. Leave out its fill and
stroke props: if either is set, it takes precedence over the class.
<Scatter
data={spainShots}
x={(s) => s.x}
y={(s) => s.y}
r={radius}
className="fill-dracula-pink/30 stroke-dracula-pink"
/>A class applies to every marker in a layer, so each colour needs its own layer. The example
draws four: goals and other shots for each team, filtered from the same array. Opacity
modifiers such as /30 work as they do anywhere else in Tailwind.
To colour by a continuous value (xG on a colour scale, for instance), use the fill prop
with a function instead.
Related
- Theming: the
--pitch-*variables, dark mode, and per-chart overrides without Tailwind. - Tailwind: setting variables with arbitrary properties, and styling marks you didn't render yourself.
- StatsBomb events: loading the match data this example uses.