RadarChart
A football radar — one axis per metric, each on its own range, with translucent shapes for one to three players and axis labels that open your own detail view.
"use client";import { RadarChart } from "@pitchkit/react";// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the// 168 outfield players with 270+ minutes at the tournament, so the rim is// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb// open data; the chart computes none of it.const metrics = [ { id: "npxg", label: "npxG", min: 0, max: 0.34 }, { id: "shots", label: "Shots", min: 0, max: 3 }, { id: "xa", label: "xA", min: 0, max: 0.24 }, { id: "kp", label: "Key passes", min: 0, max: 2.34 }, { id: "drib", label: "Dribbles", min: 0, max: 2.25 }, { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true }, { id: "press", label: "Pressures", min: 4.82, max: 22.2 }, { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },];// Lamine Yamal at Euro 2024, per 90.const yamal = { npxg: 0.28, shots: 3.13, xa: 0.37, kp: 3.13, drib: 2.09, to: 4.18, press: 19.49, ti: 1.57,};/** * One player against the tournament. Turnovers are flipped, so outward is * always better; xA is past the 95th percentile, so it is pinned to * the rim rather than off the chart. */export function RadarChartBasic() { return ( <RadarChart metrics={metrics} series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]} /> );}<RadarChart> is the player radar analysts use to profile a player: one axis per metric, each with
its own range, and a shape joining the player's values. Lower-is-better metrics are flipped so that
outward is always better.
It is a root, not a layer, like the other charts: no <Pitch>, no type prop.
Usage
const metrics = [
{ id: "npxg", label: "npxG", min: 0, max: 0.34 },
{ id: "shots", label: "Shots", min: 0, max: 3 },
{ id: "turnovers", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true },
// ...at least three
];
<RadarChart metrics={metrics} series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]} />;metricsare the axes, drawn clockwise from the top. Each has anid, an optionallabel, and aminandmax(0–100 if you leave them out).seriesare the shapes.valuesis keyed by metric id, so a row from your own data usually drops straight in.
The chart computes nothing
<RadarChart> draws the values you give it, on the ranges you give it. Per-90s, percentiles and
the ranges themselves are yours to compute. The only arithmetic is placing each value between its
metric's min and max.
A common choice for the range is the 5th to 95th percentile of the comparison population, which is StatsBomb's convention: a value at the rim is top 5%, and one at the centre is bottom 5%. That is what the examples here use, derived from every outfield player with 270 or more minutes at Euro 2024.
Lower is better
{ id: "turnovers", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true }The axis runs from max at the centre to min at the rim, so a careful player's shape still
reaches outward. The label carries a ↓, the ring values count down, and the readout says "lower is
better".
Out of range and missing values
A value beyond its axis is pinned to the edge, so the shape never claims more than the range shows. The readout still gives the real number, marked "off scale". In the example above, Yamal's xA is past the 95th percentile.
A missing or non-finite value takes the outline to the centre on that axis, and the readout says "No data". There are no markers at the vertices: the outline bends at each value, and the readout gives the numbers.
Comparing players
"use client";import { RadarChart } from "@pitchkit/react";// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the// 168 outfield players with 270+ minutes at the tournament, so the rim is// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb// open data; the chart computes none of it.const metrics = [ { id: "npxg", label: "npxG", min: 0, max: 0.34 }, { id: "shots", label: "Shots", min: 0, max: 3 }, { id: "xa", label: "xA", min: 0, max: 0.24 }, { id: "kp", label: "Key passes", min: 0, max: 2.34 }, { id: "drib", label: "Dribbles", min: 0, max: 2.25 }, { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true }, { id: "press", label: "Pressures", min: 4.82, max: 22.2 }, { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },];// Euro 2024, per 90.const players = [ { id: "yamal", label: "Yamal", values: { npxg: 0.28, shots: 3.13, xa: 0.37, kp: 3.13, drib: 2.09, to: 4.18, press: 19.49, ti: 1.57, }, }, { id: "williams", label: "N. Williams", values: { npxg: 0.17, shots: 2.14, xa: 0.22, kp: 2.14, drib: 2.68, to: 3.75, press: 18.95, ti: 0.36, }, }, { id: "saka", label: "Saka", values: { npxg: 0.07, shots: 1.12, xa: 0.05, kp: 1.12, drib: 1.54, to: 2.1, press: 9.38, ti: 0.56, }, },];/** * Three wingers on one radar. Each takes the next series colour by position, * so removing one never repaints the others. Three is the most that stay * readable overlaid; past that, draw small multiples. */export function RadarChartCompareBasic() { return <RadarChart metrics={metrics} series={players} />;}Pass more than one series and each gets a light wash and an outline in its own colour, with a legend. Three is the most that stay readable. Every shape overlaps every other, and past three the colours can't be told apart reliably, especially with colour-blindness. A fourth series still draws, with a development warning; small multiples are the better answer.
A single series is drawn in two tones of its colour where it crosses the rings, mplsoccer's look.
Turn that off with appearance={{ bands: false }}.
Label rotation
"use client";import { useState } from "react";import { RadarChart } from "@pitchkit/react";import type { LabelRotation } from "@pitchkit/react";// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the// 168 outfield players with 270+ minutes at the tournament, so the rim is// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb// open data; the chart computes none of it.const metrics = [ { id: "npxg", label: "npxG", min: 0, max: 0.34 }, { id: "shots", label: "Shots", min: 0, max: 3 }, { id: "xa", label: "xA", min: 0, max: 0.24 }, { id: "kp", label: "Key passes", min: 0, max: 2.34 }, { id: "drib", label: "Dribbles", min: 0, max: 2.25 }, { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true }, { id: "press", label: "Pressures", min: 4.82, max: 22.2 }, { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },];const yamal = { npxg: 0.28, shots: 3.13, xa: 0.37, kp: 3.13, drib: 2.09, to: 4.18, press: 19.49, ti: 1.57,};const rotations: LabelRotation[] = ["tangent", "radial", "horizontal"];/** The same radar with each label rotation. Tangent is the default. */export function RadarChartRotationBasic() { const [rotation, setRotation] = useState<LabelRotation>("tangent"); return ( <div> <div className="mb-3 flex gap-2" role="group" aria-label="Label rotation"> {rotations.map((r) => ( <button key={r} type="button" aria-pressed={rotation === r} onClick={() => setRotation(r)} className="rounded-md border border-fd-border px-2.5 py-1 font-mono text-xs aria-pressed:bg-fd-primary/10 aria-pressed:text-fd-primary" > {r} </button> ))} </div> <RadarChart metrics={metrics} series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]} labelRotation={rotation} /> </div> );}labelRotation decides how labels sit round the rim:
| Value | Labels |
|---|---|
"tangent" (default) | Run along the rim, at right angles to the axis, as mplsoccer does |
"radial" | Run outward along the axis line; suits long names |
"horizontal" | Stay upright, anchored by side |
Labels that would read upside down are turned 180°, and long labels wrap onto two lines. The ring values follow the same rotation.
Click a label to open a detail view
"use client";import { cropForHalf, getPitchDimensions } from "@pitchkit/core";import { RadarChart, Scatter, VerticalPitch } from "@pitchkit/react";import type { RadarMetric } from "@pitchkit/react";// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the// 168 outfield players with 270+ minutes at the tournament, so the rim is// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb// open data; the chart computes none of it.const metrics = [ { id: "npxg", label: "npxG", min: 0, max: 0.34 }, { id: "shots", label: "Shots", min: 0, max: 3 }, { id: "xa", label: "xA", min: 0, max: 0.24 }, { id: "kp", label: "Key passes", min: 0, max: 2.34 }, { id: "drib", label: "Dribbles", min: 0, max: 2.25 }, { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true }, { id: "press", label: "Pressures", min: 4.82, max: 22.2 }, { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },];const yamal = { npxg: 0.28, shots: 3.13, xa: 0.37, kp: 3.13, drib: 2.09, to: 4.18, press: 19.49, ti: 1.57,};// His non-penalty shots (StatsBomb coordinates, 120 x 80).const shots = [ { x: 106.5, y: 49.9, xg: 0.048, goal: false }, { x: 108.6, y: 48.3, xg: 0.282, goal: false }, { x: 91.3, y: 45.4, xg: 0.042, goal: false }, { x: 103.5, y: 49.7, xg: 0.162, goal: false }, { x: 107.2, y: 50.2, xg: 0.164, goal: false }, { x: 93.2, y: 47.1, xg: 0.027, goal: true }, { x: 97.7, y: 49.9, xg: 0.024, goal: false }, { x: 100.4, y: 51.7, xg: 0.054, goal: false }, { x: 98.9, y: 48.6, xg: 0.084, goal: false }, { x: 89.1, y: 49.3, xg: 0.027, goal: false }, { x: 94.3, y: 48.5, xg: 0.031, goal: false }, { x: 92.7, y: 49.6, xg: 0.016, goal: false }, { x: 99.6, y: 43.0, xg: 0.084, goal: false }, { x: 101.0, y: 47.7, xg: 0.076, goal: false }, { x: 104.9, y: 41.5, xg: 0.076, goal: false }, { x: 114.8, y: 47.3, xg: 0.286, goal: false }, { x: 107.6, y: 49.0, xg: 0.082, goal: false }, { x: 97.3, y: 55.7, xg: 0.022, goal: false },];// Raw counts per match, for every other metric's breakdown.const games = [ { opponent: "Croatia", npxg: 0.372, shots: 3, xa: 0.565, kp: 3, drib: 2, to: 2, press: 26, ti: 1, }, { opponent: "England", npxg: 0.326, shots: 2, xa: 0.382, kp: 3, drib: 0, to: 4, press: 19, ti: 1, }, { opponent: "France", npxg: 0.105, shots: 3, xa: 0.242, kp: 2, drib: 0, to: 4, press: 12, ti: 1, }, { opponent: "Germany", npxg: 0.112, shots: 2, xa: 0.389, kp: 3, drib: 2, to: 7, press: 13, ti: 3, }, { opponent: "Georgia", npxg: 0.65, shots: 7, xa: 0.542, kp: 6, drib: 3, to: 2, press: 16, ti: 1, }, { opponent: "Albania", npxg: 0, shots: 0, xa: 0.029, kp: 1, drib: 1, to: 1, press: 9, ti: 0, }, { opponent: "Italy", npxg: 0.022, shots: 1, xa: 0, kp: 0, drib: 4, to: 4, press: 17, ti: 2, },];const half = cropForHalf(getPitchDimensions("statsbomb"));/** What opens in place of the chart. Anything can go here. */function Detail({ metric }: { metric: RadarMetric }) { if (metric.id === "npxg" || metric.id === "shots") { return ( <VerticalPitch type="statsbomb" crop={half} className="mx-auto max-w-xs"> <Scatter data={shots} x={(s) => s.x} y={(s) => s.y} r={(s) => 3 + s.xg * 12} fill={(s) => (s.goal ? "var(--pitch-marker-goal)" : "var(--pitch-marker-primary)")} stroke="white" tooltip={(s) => `xG ${s.xg.toFixed(2)}${s.goal ? " · goal" : ""}`} /> </VerticalPitch> ); } const key = metric.id as keyof (typeof games)[number]; const most = Math.max(1, ...games.map((g) => Number(g[key]))); return ( <ul className="space-y-1.5 text-sm"> {games.map((g) => ( <li key={g.opponent} className="grid grid-cols-[6rem_1fr_3rem] items-center gap-2"> <span className="truncate text-fd-muted-foreground">v {g.opponent}</span> <span className="h-2 rounded-full bg-fd-primary/70" style={{ width: `${(Number(g[key]) / most) * 100}%` }} /> <span className="text-right tabular-nums"> {Number(g[key]).toFixed(metric.id === "xa" ? 2 : 0)} </span> </li> ))} </ul> );}/** Click an axis label (or focus it and press Enter) to open its breakdown. */export function RadarChartDetailBasic() { return ( <RadarChart metrics={metrics} series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]} renderDetail={({ metric }) => <Detail metric={metric} />} /> );}Pass renderDetail and every axis label becomes a button. Activating one replaces the chart with
whatever you return, in the same box, under a header with a Back button:
<RadarChart
metrics={metrics}
series={series}
renderDetail={({ metric, values, close }) => <MetricBreakdown metric={metric} values={values} />}
/>renderDetail receives the metric, every series' value for it in values (keyed by series id),
and close(). Back, Escape and close() all return to the chart, and focus goes back to the label
you opened. Labels are reachable with Tab and open with Enter or Space.
Without renderDetail, labels are plain text: nothing looks clickable that isn't.
To keep the selection in the URL, control it:
const [selected, setSelected] = useState<RadarSelection | null>(null);
<RadarChart ... renderDetail={...} selected={selected} onSelectedChange={setSelected} />;Reading values
Hover anywhere on the chart, or focus a label, and the readout shows every series' value on the nearest axis. On a touch screen a tap does the same and stays up until you tap outside the chart.
Annotations
Children are drawn over the shapes and positioned with useRadarChart(). pointAt(metricId, value) uses the same range and flip as the shapes, so a benchmark lines up exactly:
function LeagueAverage({ values }: { values: Record<string, number> }) {
const { pointAt } = useRadarChart();
const points = Object.entries(values).flatMap(([id, v]) => pointAt(id, v) ?? []);
return (
<polygon points={points.join(" ")} fill="none" stroke="currentColor" strokeDasharray="3 3" />
);
}It also returns cx, cy, the inner and outer radii, and angleOf(metricId).
Sizing
Responsive by default and square. Passing width and height is the opt-out, and
aspectRatio changes the box (the chart stays centred). Below 420px wide the labels shrink a step
and the ring values are hidden, since they collide there; turn them back on with
appearance={{ rangeLabels: true }}.
Theming
CSS variables only.
| Variable | What it colours |
|---|---|
--pitch-series-1 … -3 | The shapes, by position in series |
--pitch-grid | The range bands |
--pitch-axis | The spokes |
--pitch-chart-text | Labels and the legend |
--pitch-chart-muted | Ring values |
--pitch-chart-accent | The detail view's Back button |
--pitch-chart-accent-text | The Back button's label |
Every part of a series paints with currentColor, so one class recolours all of it:
series={[{ id: "yamal", values: yamal, className: "text-rose-500" }]}A series with a className and no color drops its themed default, the same rule as every mark
layer. appearance is structural only: rangeLabels, legend and bands.
Every element carries a data-pitchkit-part for Tailwind arbitrary variants: radar-band,
radar-spoke, radar-shape, radar-band-tone, radar-tick, radar-label,
radar-legend, radar-detail, radar-back. Series groups carry data-pitchkit-series, labels
data-pitchkit-metric, and the root data-pitchkit-layer="radar".