PitchKit
Charts

PizzaChart

A football pizza — one slice per metric, as long as the value, coloured by group, with one to three players side by side or two overlaid, and slices that open your own detail view.

AttackingPossessionDefending91npxG96Shots100xA99Key passes93Dribbles93Turnovers ↓91Pressures69Tackles+ Int
"use client";import { PizzaChart } from "@pitchkit/react";// Each value is a percentile: how many of the 168 outfield players with 270+// minutes at Euro 2024 this player beat on that stat per 90 (0 = fewest,// 100 = most). Derived once from StatsBomb open data; the chart computes// none of it. Turnovers are flipped by `lowerIsBetter`, so a long slice is// always the good one.const metrics = [  { id: "npxg", label: "npxG", group: "Attacking" },  { id: "shots", label: "Shots", group: "Attacking" },  { id: "xa", label: "xA", group: "Attacking" },  { id: "kp", label: "Key passes", group: "Attacking" },  { id: "drib", label: "Dribbles", group: "Possession" },  { id: "to", label: "Turnovers", group: "Possession", lowerIsBetter: true },  { id: "press", label: "Pressures", group: "Defending" },  { id: "ti", label: "Tackles + Int", group: "Defending" },];// Lamine Yamal at Euro 2024.const yamal = { npxg: 91, shots: 96, xa: 100, kp: 99, drib: 93, to: 93, press: 91, ti: 69 };/** * One player, coloured by group: attacking, possession and defending take * the first three series colours in order. The tinted part of each slice is * the rest of the way to the 100th percentile. */export function PizzaChartBasic() {  return (    <PizzaChart      metrics={metrics}      series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]}    />  );}

<PizzaChart> is the percentile pizza analysts use to profile a player: one slice per metric, as long as the value, coloured by category, around a hole in the middle. It is a polar bar chart, so lengths read directly against the dashed rings at each quarter.

It is a root, not a layer, like the other charts: no <Pitch>, no type prop.

Usage

const metrics = [
  { id: "npxg", label: "npxG", group: "Attacking" },
  { id: "shots", label: "Shots", group: "Attacking" },
  { id: "turnovers", label: "Turnovers", group: "Possession", lowerIsBetter: true },
  // ...at least three
];

<PizzaChart metrics={metrics} series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]} />;
  • metrics are the slices, drawn clockwise from the top. Each has an id, an optional label and group, and a min and max (0–100 if you leave them out, which is what a percentile is).
  • series are the players. values is keyed by metric id, so a row from your own data usually drops straight in.

The same metrics shape feeds <RadarChart>, apart from group, so one list can drive either chart.

The chart computes nothing

<PizzaChart> draws the values you give it. Percentiles, per-90s and the population they are ranked against are yours to compute, and the population is a real choice: the same player has a different percentile against a league than against a tournament. The examples here rank against the 168 outfield players with 270 or more minutes at Euro 2024, derived once from StatsBomb open data.

Lower is better

{ id: "turnovers", label: "Turnovers", group: "Possession", lowerIsBetter: true }

A percentile of turnovers is high for a player who loses the ball a lot. lowerIsBetter flips the slice so a long one is always the good one, the label carries a ↓, and the readout says "lower is better". Pass the raw percentile and let the flag do the flip.

Colour

With one series the slices take their group's colour. Groups take --pitch-series-1, -2, -3… in order of first appearance, and the tinted rest of each slice is the same colour, so the chart reads as three categories at a glance. With several series, colour identifies the player and the groups on the rim take the slots after the series'. Override a group with a colour or a class:

<PizzaChart
  ...
  groups={{
    Attacking: { className: "text-sky-500" },
    Possession: { className: "text-emerald-500" },
    Defending: { color: "var(--my-defending)" },
  }}
/>

A group (or series) given a className and no color drops its themed default, the same rule as every mark layer. Every part paints with currentColor, so one class recolours the slice, its tint and its value box.

Comparing players

YamalN. WilliamsSaka918061npxG968363Shots1009356xA999172Key passes939788Dribbles938968Turnovers ↓918934Pressures69815Tackles+ Int
"use client";import { PizzaChart } from "@pitchkit/react";// Each value is a percentile: how many of the 168 outfield players with 270+// minutes at Euro 2024 this player beat on that stat per 90 (0 = fewest,// 100 = most). Derived once from StatsBomb open data; the chart computes// none of it. Turnovers are flipped by `lowerIsBetter`, so a long slice is// always the good one.const metrics = [  { id: "npxg", label: "npxG", group: "Attacking" },  { id: "shots", label: "Shots", group: "Attacking" },  { id: "xa", label: "xA", group: "Attacking" },  { id: "kp", label: "Key passes", group: "Attacking" },  { id: "drib", label: "Dribbles", group: "Possession" },  { id: "to", label: "Turnovers", group: "Possession", lowerIsBetter: true },  { id: "press", label: "Pressures", group: "Defending" },  { id: "ti", label: "Tackles + Int", group: "Defending" },];// Euro 2024.const players = [  {    id: "yamal",    label: "Yamal",    values: { npxg: 91, shots: 96, xa: 100, kp: 99, drib: 93, to: 93, press: 91, ti: 69 },  },  {    id: "williams",    label: "N. Williams",    values: { npxg: 80, shots: 83, xa: 93, kp: 91, drib: 97, to: 89, press: 89, ti: 8 },  },  {    id: "saka",    label: "Saka",    values: { npxg: 61, shots: 63, xa: 56, kp: 72, drib: 88, to: 68, press: 34, ti: 15 },  },];/** * Three players on one pizza. Each metric's slice is split into a thin wedge * per player, coloured by player, with the group shown as an arc on the rim. * Three is the most that stay readable side by side. */export function PizzaChartCompareBasic() {  return <PizzaChart metrics={metrics} series={players} />;}

With several series the slices are coloured by series, with a legend, and each metric's group moves to an arc on the rim. Each series gets its own slice within a metric:

YamalN. Williams9180npxG9683Shots10093xA9991Key passes9793Dribbles9389Turnovers ↓9189Pressures698Tackles+ Int
"use client";import { useState } from "react";import { PizzaChart } from "@pitchkit/react";import type { PizzaSeriesLayout } from "@pitchkit/react";// Each value is a percentile: how many of the 168 outfield players with 270+// minutes at Euro 2024 this player beat on that stat per 90 (0 = fewest,// 100 = most). Derived once from StatsBomb open data; the chart computes// none of it. Turnovers are flipped by `lowerIsBetter`, so a long slice is// always the good one.const metrics = [  { id: "npxg", label: "npxG", group: "Attacking" },  { id: "shots", label: "Shots", group: "Attacking" },  { id: "xa", label: "xA", group: "Attacking" },  { id: "kp", label: "Key passes", group: "Attacking" },  { id: "drib", label: "Dribbles", group: "Possession" },  { id: "to", label: "Turnovers", group: "Possession", lowerIsBetter: true },  { id: "press", label: "Pressures", group: "Defending" },  { id: "ti", label: "Tackles + Int", group: "Defending" },];// Euro 2024.const players = [  {    id: "yamal",    label: "Yamal",    values: { npxg: 91, shots: 96, xa: 100, kp: 99, drib: 93, to: 93, press: 91, ti: 69 },  },  {    id: "williams",    label: "N. Williams",    values: { npxg: 80, shots: 83, xa: 93, kp: 91, drib: 97, to: 89, press: 89, ti: 8 },  },];const layouts: PizzaSeriesLayout[] = ["side-by-side", "overlay"];/** * How two players share a slice. Side by side keeps both fully visible; * overlay draws the larger first so the smaller always shows on top, and * suits two players only. */export function PizzaChartLayoutBasic() {  const [layout, setLayout] = useState<PizzaSeriesLayout>("overlay");  return (    <div>      <div className="mb-3 flex gap-2" role="group" aria-label="Series layout">        {layouts.map((l) => (          <button            key={l}            type="button"            aria-pressed={layout === l}            onClick={() => setLayout(l)}            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"          >            {l}          </button>        ))}      </div>      <PizzaChart metrics={metrics} series={players} seriesLayout={layout} />    </div>  );}

seriesLayout decides how they share it:

ValueSlices
"side-by-side" (default)Split the metric's wedge into one thin wedge per series. Nothing is hidden. Readable up to three.
"overlay"Use the full wedge for every series, largest drawn first so a smaller one stays visible on top. Two only.

Past the limit a series still draws, with a development warning. Overlaying a third hides the middle value behind the other two, and a fourth side-by-side wedge is too thin to read.

Value boxes are on by default: at the tip for one series, in a lane per series for an overlay (so close values never print on top of each other), and centred on each series' wedge side by side. appearance={{ values: false }} turns them off. A box is dropped on a slice too narrow to hold it.

Label rotation

labelRotation is "tangent" (default), "radial" or "horizontal", the same as <RadarChart>: labels follow the slice's angle, those that would read upside down turn 180°, and long ones wrap.

Click a slice to open a detail view

AttackingPossessionDefending91npxG96Shots100xA99Key passes93Dribbles93Turnovers ↓91Pressures69Tackles+ Int
"use client";import { cropForHalf, getPitchDimensions } from "@pitchkit/core";import { PizzaChart, Scatter, VerticalPitch } from "@pitchkit/react";import type { PizzaMetric } from "@pitchkit/react";// Each value is a percentile: how many of the 168 outfield players with 270+// minutes at Euro 2024 this player beat on that stat per 90 (0 = fewest,// 100 = most). Derived once from StatsBomb open data; the chart computes// none of it. Turnovers are flipped by `lowerIsBetter`, so a long slice is// always the good one.const metrics = [  { id: "npxg", label: "npxG", group: "Attacking" },  { id: "shots", label: "Shots", group: "Attacking" },  { id: "xa", label: "xA", group: "Attacking" },  { id: "kp", label: "Key passes", group: "Attacking" },  { id: "drib", label: "Dribbles", group: "Possession" },  { id: "to", label: "Turnovers", group: "Possession", lowerIsBetter: true },  { id: "press", label: "Pressures", group: "Defending" },  { id: "ti", label: "Tackles + Int", group: "Defending" },];// Lamine Yamal at Euro 2024.const yamal = { npxg: 91, shots: 96, xa: 100, kp: 99, drib: 93, to: 93, press: 91, ti: 69 };// 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: PizzaMetric }) {  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 a slice (or focus it and press Enter) to open its breakdown. */export function PizzaChartDetailBasic() {  return (    <PizzaChart      metrics={metrics}      series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]}      renderDetail={({ metric }) => <Detail metric={metric} />}    />  );}

Pass renderDetail and every slice becomes a button. Activating one replaces the chart with whatever you return, in the same box, under a header with a Back button:

<PizzaChart
  metrics={metrics}
  series={series}
  renderDetail={({ metric, series, values, close }) => (
    <MetricBreakdown player={series.id} metric={metric} />
  )}
/>

renderDetail receives the metric, the series whose slice was clicked, every series' value for that metric in values (keyed by series id), and close(). Back, Escape and close() all return to the chart, and focus goes back to the slice you opened. Slices are reachable with Tab and open with Enter or Space, and each is announced as, for example, "Shots, Lamine Yamal, 96. Open details".

Without renderDetail, slices are plain graphics: nothing looks clickable that isn't. To keep the selection in the URL, control it with selected and onSelectedChange, whose value is { metricId, seriesId }.

Reading values

Hover anywhere in a slice, or focus it, and the readout shows every series' value for that metric. On a touch screen a tap does the same and stays up until you tap outside the chart.

Annotations

Children are drawn over the slices and positioned with usePizzaChart(). pointAt(metricId, value) uses the same range and flip as the slices and returns a point along the slice's middle:

function Median({ metricId }: { metricId: string }) {
  const { pointAt } = usePizzaChart();
  const [x, y] = pointAt(metricId, 50) ?? [];
  return x === undefined ? null : <circle cx={x} cy={y} r={3} fill="currentColor" />;
}

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. Below 420px wide the labels shrink a step.

Theming

CSS variables only.

VariableWhat it colours
--pitch-series-1 … -6Groups (one series) or series (several), in order
--pitch-axisThe rings
--pitch-gridThe empty part of an overlaid slice
--pitch-chart-surfaceThe edge between slices; value boxes
--pitch-chart-textLabels, the legend and values
--pitch-chart-accentThe detail view's Back button
--pitch-chart-accent-textThe Back button's label

appearance is structural only: values and legend. Every element carries a data-pitchkit-part for Tailwind arbitrary variants: pizza-wedge, pizza-slice, pizza-blank, pizza-value, pizza-rim, pizza-label, pizza-ring, pizza-legend, pizza-detail, pizza-back. Slices carry data-pitchkit-metric and data-pitchkit-series, and the root data-pitchkit-layer="pizza".

On this page