MomentumChart
Match momentum as bars above and below a zero line, one panel per half, with goals, cards and other events on a row of icons beneath.
"use client";import { MomentumChart } from "@pitchkit/react";// Momentum every three minutes in each half. The interval is yours to pick —// nothing has to be one minute — but use the same one in both halves, or the// bars come out different widths on either side of half time.// Positive is the home side's pressure, negative the away side's.const periods = [ [ { minute: 0, value: 2 }, { minute: 3, value: 5 }, { minute: 6, value: 8 }, { minute: 9, value: 4 }, { minute: 12, value: -1 }, { minute: 15, value: -4 }, { minute: 18, value: -7 }, { minute: 21, value: -3 }, { minute: 24, value: 1 }, { minute: 27, value: 4 }, { minute: 30, value: 7 }, { minute: 33, value: 5 }, { minute: 36, value: 2 }, { minute: 39, value: -2 }, { minute: 42, value: -5 }, ], [ { minute: 45, value: -2 }, { minute: 48, value: -5 }, { minute: 51, value: -8 }, { minute: 54, value: -4 }, { minute: 57, value: 1 }, { minute: 60, value: 5 }, { minute: 63, value: 9 }, { minute: 66, value: 7 }, { minute: 69, value: 3 }, { minute: 72, value: -1 }, { minute: 75, value: -4 }, { minute: 78, value: -6 }, { minute: 81, value: -2 }, { minute: 84, value: 3 }, { minute: 87, value: 7 }, ],];// Events are a separate list, with accessors, like the samples.const events = [ { minute: 23, side: "away", kind: "goal" }, { minute: 38, side: "home", kind: "yellow-card" }, { minute: 56, side: "home", kind: "goal" }, { minute: 71, side: "away", kind: "red-card" }, { minute: 81, side: "away", kind: "missed-penalty" }, { minute: 88, side: "home", kind: "goal" },] as const;export function MomentumChartBasic() { return ( <MomentumChart periods={periods} time={(d) => d.minute} value={(d) => d.value} teams={{ home: "Home", away: "Away" }} events={events} eventTime={(e) => e.minute} eventSide={(e) => e.side} eventKind={(e) => e.kind} /> );}<MomentumChart> draws who had the upper hand, minute by minute. The home side's pressure rises
above a zero line and the away side's falls below it; each half gets its own panel, and the events
that shaped it — goals, cards, a missed penalty — sit on a row of icons underneath.
It is a root, not a layer, like <RaceChart>: no <Pitch>, no type
prop. See Charts for why.
Where the numbers come from
PitchKit draws momentum; it does not compute it. There is no standard momentum metric — vendors each publish their own and none of them is in an open feed — so you bring the values. A StatsBomb recipe is below for when all you have is an event stream.
Usage
<MomentumChart
periods={[firstHalf, secondHalf]}
time={(d) => d.minute}
value={(d) => d.value}
teams={{ home: "Roma", away: "Barcelona" }}
events={events}
eventTime={(e) => e.minute}
eventSide={(e) => e.side}
eventKind={(e) => e.kind}
/>periods is one array of samples per period: [firstHalf, secondHalf], and two more for extra
time. time and value are accessors like every other PitchKit prop.
value is signed. Positive is the home side's pressure and negative is the away side's, so one
number per sample carries both teams. Zero is level.
Data at any interval
Samples do not need to be a minute apart, or evenly spaced. The example above has one every three minutes. Use the same interval in both halves, though: two halves at different intervals draw bars of different widths either side of half time, which reads as a bug. In development the chart warns when the median bar widths of two periods differ by more than a quarter.
Each sample draws a bar that runs from its minute to the next sample's minute. That is what makes an arbitrary interval readable: a value given at 10' and the next at 15' is five minutes wide, not a thin sliver followed by a hole. The last bar of a period has no successor, so it takes the median interval of the period — one minute, if there is only a single sample.
A few consequences worth knowing:
- Gaps draw nothing. If you leave out a stretch, it is empty, and hovering it reads "No data", not "Level". Level is a value of 0 and means something.
- Duplicates: the later one wins. Samples are sorted by time first, so input order doesn't matter.
- Non-finite values are dropped, not drawn as zero.
Halves, and stoppage time
A panel's width is proportional to its minutes, so a half with seven minutes of stoppage time is wider than one with two, and a bar is the same width in either half for the same number of minutes.
Each period starts at its nominal minute (0, 45, 90, 105) and ends at the later of its nominal end
(45, 90, 105, 120) and its last sample. Half time therefore comes from the data, for the same reason
as in the race chart: halves do not end on 45. Pass periodRanges to override either end:
<MomentumChart ... periodRanges={[undefined, { start: 45, end: 95 }]} />Extra time is two more arrays in periods; the axis then ends at 105 and 120. Periods beyond the
fourth continue in 15-minute blocks.
The value axis
The axis is symmetric about zero and spans the largest magnitude in the data, rounded up to a round
number, so the two teams are always on the same scale. Pin it with maxValue — useful for putting
several matches side by side on one scale.
A bar that would be under a pixel tall is drawn at one pixel, so a small but real value never vanishes.
Events
Events are a separate list, passed like the race chart's annotations: a list plus accessors.
| Prop | What it is |
|---|---|
events | The list |
eventTime | Match minute |
eventSide | "home" or "away" |
eventKind | One of the kinds below |
eventLabel | Optional text for the readout and screen readers |
These come as a set: pass events and TypeScript requires the three accessors, so a missing one is
a compile error rather than an empty icon row.
| Kind | Icon | Colour |
|---|---|---|
goal | Ball | Team |
own-goal | Ball with an arrow turning back | --pitch-card-red |
missed-penalty | Ball struck through | Team |
yellow-card | Card | --pitch-card-yellow |
red-card | Card | --pitch-card-red |
substitution | Up and down arrows | Team |
var | Screen showing a V | Team |
goal includes a scored penalty — most feeds record those as goals. A missed one is its own kind,
missed-penalty, because it is the event people look for.
Cards and own goals carry their meaning in their colour, so they cannot also carry the team. Those
get a small underline in the team's colour; every other icon is simply the team's colour. The
icons are PitchKit's own, drawn for this library on a 24-unit grid, and inherit currentColor.
Keep the row readable
The row is always one row tall. Icons that would touch stack with an offset: each sits a little right of the last and over it, on a backing in the chart surface colour, and a stack is centred on where its events really were. A goal and a booking in the same minute read as two icons, not one smudge. Every substitution in a close finish is still a pile, especially on a phone. If you want substitutions, prefer them as annotations — a thin line rather than an icon — or leave them out, which is what the examples do.
Reading a value at a minute
Hover or tap a panel. A crosshair marks the minute, and a readout gives the team and value of the
bar under it, plus any events at that minute. On a touch screen a horizontal drag scrubs the
crosshair and a vertical one still scrolls the page. Pass tooltip to replace the body:
<MomentumChart ... tooltip={(h) => `${Math.round(h.minute)}' · ${h.datum?.value ?? "no data"}`} />h.datum is your own sample behind the bar, so a custom readout can show fields the chart never
saw.
Annotations
Anything the built-in kinds don't cover is a child, positioned through useMomentumChart():
function Change({ minute }: { minute: number }) {
const { scaleX, frame } = useMomentumChart();
const x = scaleX(minute);
return <line x1={x} x2={x} y1={frame.y0} y2={frame.y1} />;
}It returns frame (the bars' rectangle), panels, scaleX(minute) (into whichever half holds that
minute), scaleY(value), and the computed bars. Children paint above the bars and below the
crosshair.
"use client";import { MomentumChart, useMomentumChart } from "@pitchkit/react";const periods = [ Array.from({ length: 10 }, (_, i) => ({ minute: i * 5, value: Math.round(8 * Math.sin(i / 1.6)), })), Array.from({ length: 10 }, (_, i) => ({ minute: 45 + i * 5, value: Math.round(9 * Math.cos(i / 1.4)), })),];const substitutions = [60, 70, 78];/** * A marker for something the built-in kinds don't cover. `scaleX` turns a * minute into a pixel in whichever half holds it, and `frame` gives the bars' * rectangle, so this line runs the full height of the plot. */function Change({ minute }: { minute: number }) { const { scaleX, frame } = useMomentumChart(); const x = scaleX(minute); return ( <line x1={x} x2={x} y1={frame.y0} y2={frame.y1} strokeDasharray="2 3" style={{ stroke: "var(--pitch-chart-text)", strokeOpacity: 0.7 }} > <title>{`Substitution, ${minute}'`}</title> </line> );}export function MomentumChartAnnotationsBasic() { return ( <MomentumChart periods={periods} time={(d) => d.minute} value={(d) => d.value}> {substitutions.map((minute) => ( <Change key={minute} minute={minute} /> ))} </MomentumChart> );}Sizing
Responsive by default: it fills its container, so sizing one means sizing its parent. Passing
width and height is the opt-out. The box is 3:1, and below 420px wide it is 1.8:1 with
smaller icons, because a 3:1 chart on a phone leaves bars a few pixels tall.
Theming
CSS variables only.
| Variable | What it colours |
|---|---|
--pitch-series-1 | The home side's bars and icons |
--pitch-series-2 | The away side's bars and icons |
--pitch-card-yellow | Yellow cards |
--pitch-card-red | Red cards and own goals |
--pitch-grid | The panel backgrounds |
--pitch-axis | The zero line |
--pitch-chart-surface | Halos and outlines |
--pitch-chart-text | Labels and the readout |
--pitch-chart-muted | Minute ticks |
appearance is structural only: axis (minute ticks, default on) and legend (the two teams and
which way is which, default on when teams is given).
Every element carries a data-pitchkit-part for Tailwind arbitrary variants: momentum-bar,
momentum-zero, momentum-label, momentum-event, momentum-crosshair. Bars and events carry
data-pitchkit-side, events data-pitchkit-kind, and the root data-pitchkit-layer="momentum".
Live data
The same chart against a real Euro 2024 match, fetched in the browser.
Fetching the match from StatsBomb open data (~3 MB)…
"use client";import { useEffect, useState } from "react";import { MomentumChart } from "@pitchkit/react";import type { MomentumEventKind } from "@pitchkit/react";import { fetchMatchEvents, isGoal, shots } from "@pitchkit/data-providers/statsbomb";import type { StatsBombEvent } from "@pitchkit/data-providers/statsbomb";import { DEFAULT_MATCH_ID, controlClass, matchLabel, useEuroMatches } from "./statsbomb-live";/** * Match momentum from a real Euro 2024 match, fetched in the browser. * * StatsBomb does not publish a momentum number, so this derives one, and * says so: on-ball events in the attacking third, per minute, home minus * away, smoothed over three minutes. It is a stand-in for pressure, not * anyone's official metric. * * The attacking third is `x >= 80` for every team, because StatsBomb * orients each team to attack towards x = 120 in both halves. */const ON_BALL = new Set([ "Pass", "Carry", "Shot", "Dribble", "Ball Receipt*", "Duel", "Interception", "Ball Recovery", "Clearance", "Miscontrol", "Dispossessed",]);interface Sample { minute: number; value: number;}interface MatchEvent { minute: number; side: "home" | "away"; kind: MomentumEventKind; label: string;}/** One array of samples per period, the shape `<MomentumChart>` takes. */function derivePeriods(events: readonly StatsBombEvent[], home: string): Sample[][] { const periods: Sample[][] = []; // Period 5 is the penalty shootout; it has no place on a match clock. for (let period = 1; period <= 4; period++) { const inPeriod = events.filter((e) => e.period === period); if (inPeriod.length === 0) continue; const counts = new Map<number, number>(); for (const e of inPeriod) { if (!ON_BALL.has(e.type.name) || e.x === undefined || e.x < 80) continue; counts.set(e.minute, (counts.get(e.minute) ?? 0) + (e.team.name === home ? 1 : -1)); } if (counts.size === 0) continue; const first = Math.min(...inPeriod.map((e) => e.minute)); const last = Math.max(...counts.keys()); const raw = Array.from({ length: last - first + 1 }, (_, i) => counts.get(first + i) ?? 0); periods.push( raw.map((_, i) => { const window = raw.slice(Math.max(0, i - 1), i + 2); const mean = window.reduce((sum, v) => sum + v, 0) / window.length; return { minute: first + i, value: Math.round(mean * 10) / 10 }; }), ); } return periods;}/** Goals and bookings. Bookings live at `foul_committed.card` in StatsBomb's feed. */function pickEvents(events: readonly StatsBombEvent[], home: string): MatchEvent[] { const picked: MatchEvent[] = []; for (const shot of shots(events)) { if (shot.period <= 4 && isGoal(shot)) { picked.push({ minute: shot.minute + shot.second / 60, side: shot.team.name === home ? "home" : "away", kind: "goal", label: `Goal, ${shot.player?.name ?? shot.team.name}`, }); } } for (const e of events) { const card = (e as { foul_committed?: { card?: { name: string } } }).foul_committed?.card?.name; if (!card || e.period > 4) continue; picked.push({ minute: e.minute + e.second / 60, side: e.team.name === home ? "home" : "away", kind: card === "Yellow Card" ? "yellow-card" : "red-card", label: `${card}, ${e.player?.name ?? e.team.name}`, }); } return picked;}export function MomentumChartStatsbombBasic() { const [matchId, setMatchId] = useState(DEFAULT_MATCH_ID); const [result, setResult] = useState< { key: number; events: readonly StatsBombEvent[] } | undefined >(); const [failed, setFailed] = useState(false); const matches = useEuroMatches(); const loaded = result?.key === matchId ? result.events : undefined; useEffect(() => { fetchMatchEvents(matchId) .then((events) => setResult({ key: matchId, events })) .catch(() => setFailed(true)); }, [matchId]); const match = matches.find((m) => m.match_id === matchId); const home = match?.home_team.home_team_name ?? ""; const away = match?.away_team.away_team_name ?? ""; const periods = loaded && home ? derivePeriods(loaded, home) : []; const marks = loaded && home ? pickEvents(loaded, home) : []; return ( <div> <select aria-label="Euro 2024 match" value={matchId} disabled={matches.length === 0} onChange={(event) => { setFailed(false); setMatchId(Number(event.target.value)); }} className={`w-full min-w-0 pl-2 pr-8 sm:w-auto sm:max-w-xs ${controlClass}`} > {matches.length === 0 && <option value={DEFAULT_MATCH_ID}>Loading matches…</option>} {matches.map((m) => ( <option key={m.match_id} value={m.match_id}> {matchLabel(m)} </option> ))} </select> <p className="my-3 text-xs text-fd-muted-foreground"> {failed ? "Couldn't reach StatsBomb open data." : loaded === undefined ? "Fetching the match from StatsBomb open data (~3 MB)…" : "Derived from attacking-third on-ball events, smoothed over three minutes."} </p> <MomentumChart periods={periods} time={(d) => d.minute} value={(d) => d.value} teams={{ home, away }} events={marks} eventTime={(e) => e.minute} eventSide={(e) => e.side} eventKind={(e) => e.kind} eventLabel={(e) => e.label} /> </div> );}StatsBomb does not publish momentum, so this example derives one — a stand-in for pressure, not anyone's official metric:
- Keep on-ball events (
Pass,Carry,Shot,Dribble,Ball Receipt*,Duel,Interception,Ball Recovery,Clearance,Miscontrol,Dispossessed) that happen in the attacking third. - Count them per minute, +1 for the home side and −1 for the away side.
- Smooth with a three-minute centred moving average.
The attacking third is x >= 80 for both teams, because StatsBomb orients every team to attack
towards x = 120 in both halves. You don't flip the away side's coordinates.
The source filters to period <= 4 for the same reason the race chart does: period 5 is the
penalty shootout, and it has no place on a match clock.