PitchKit
Charts

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.

0.0850.170.260.340.751.52.330.060.120.180.240.581.21.82.30.561.11.72.33.32.21.10.049.21418220.931.52.22.8npxGShotsxAKey passesDribblesTurnovers ↓PressuresTackles+ Int
"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 }]} />;
  • metrics are the axes, drawn clockwise from the top. Each has an id, an optional label, and a min and max (0–100 if you leave them out).
  • series are the shapes. values is 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

YamalN. WilliamsSaka0.0850.170.260.340.751.52.330.060.120.180.240.581.21.82.30.561.11.72.33.32.21.10.049.21418220.931.52.22.8npxGShotsxAKey passesDribblesTurnovers ↓PressuresTackles+ Int
"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

0.0850.170.260.340.751.52.330.060.120.180.240.581.21.82.30.561.11.72.33.32.21.10.049.21418220.931.52.22.8npxGShotsxAKey passesDribblesTurnovers ↓PressuresTackles+ Int
"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:

ValueLabels
"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

0.0850.170.260.340.751.52.330.060.120.180.240.581.21.82.30.561.11.72.33.32.21.10.049.21418220.931.52.22.8npxGShotsxAKey passesDribblesTurnovers ↓PressuresTackles+ Int
"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.

VariableWhat it colours
--pitch-series-1 … -3The shapes, by position in series
--pitch-gridThe range bands
--pitch-axisThe spokes
--pitch-chart-textLabels and the legend
--pitch-chart-mutedRing values
--pitch-chart-accentThe detail view's Back button
--pitch-chart-accent-textThe 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".

On this page