Player Avatar
A player's photo, number and name on the pitch, with badges for cards, substitutions, goals and assists. Takes any image component, including next/image.
"use client";import { PlayerAvatar, VerticalPitch } from "@pitchkit/react";import type { PlayerCard, PlayerSubstitution } from "@pitchkit/react";import { docsAppearance } from "./docs-appearance";interface Player { number: number; name: string; photo: string; x: number; y: number; card?: PlayerCard; subbed?: PlayerSubstitution; goals?: number; assists?: number;}// A fictional XI: the names are invented and the photos are AI-generated faces of// people who don't exist, served from the docs site's own /players folder.//// A 4-3-3 in StatsBomb coordinates (120 x 80): on a vertical pitch x runs top to// bottom, so the keeper at x 113 is drawn at the foot, the side attacks upwards, and// y 70 (the right-back) is on the right.const lineup: Player[] = [ { number: 1, name: "J. Marlow", photo: "marlow", x: 113, y: 40 }, { number: 3, name: "T. Brennick", photo: "brennick", x: 86, y: 13 }, { number: 25, name: "S. Okonkwo-Hale", photo: "okonkwo-hale", x: 96, y: 28, card: "yellow" }, { number: 14, name: "D. Varela-Ruiz", photo: "varela-ruiz", x: 96, y: 52, subbed: "off" }, { number: 2, name: "E. Lindqvist", photo: "lindqvist", x: 86, y: 67, card: "yellow", assists: 1 }, { number: 24, name: "A. Mbeki-Soto", photo: "mbeki-soto", x: 68, y: 22 }, { number: 6, name: "R. Tanaka-Holm", photo: "tanaka-holm", x: 78, y: 40, subbed: "off" }, { number: 8, name: "L. Delacroix", photo: "delacroix", x: 68, y: 58, card: "yellow", subbed: "off", goals: 2, }, { number: 23, name: "N. Haddad-Kerr", photo: "haddad-kerr", x: 38, y: 14, card: "yellow", subbed: "off", }, { number: 11, name: "F. Ostrander", photo: "ostrander", x: 27, y: 40, subbed: "off" }, { number: 10, name: "M. Quintero-Bell", photo: "quintero-bell", x: 38, y: 66, assists: 2 },];/** `<PlayerAvatar>` puts a photo, number, name and match events at each player's position. */export function PlayerAvatarBasic() { return ( <div style={{ maxWidth: 420, margin: "0 auto" }}> <VerticalPitch type="statsbomb" appearance={docsAppearance}> {lineup.map((p) => ( <PlayerAvatar key={p.number} x={p.x} y={p.y} name={p.name} number={p.number} card={p.card} subbed={p.subbed} goals={p.goals} assists={p.assists} src={`/players/${p.photo}.webp`} /> ))} </VerticalPitch> </div> );}The players in this example are invented: their names are made up and their photos are AI-generated faces of people who don't exist.
<PlayerAvatar> is the lineup graphic from a match centre: one player at a point on the pitch,
with their photo, shirt number and name, and a badge for each thing that happened to them. Render
one per player inside <Pitch> or <VerticalPitch>.
Usage
<VerticalPitch type="statsbomb">
{lineup.map((p) => (
<PlayerAvatar
key={p.id}
x={p.x}
y={p.y}
name={p.name}
number={p.number}
card={p.card}
subbed={p.subbed}
goals={p.goals}
assists={p.assists}
src={p.photoUrl}
/>
))}
</VerticalPitch>| Prop | Type | Does |
|---|---|---|
x, y | number | Required. Where the photo's centre sits, in the pitch's type units. |
name | string | Required. Shown under the photo, and the initials when there is no photo. |
number | number | string | The shirt number, shown before the name. |
card | "yellow" | "second-yellow" | "red" | A card badge. A second yellow draws the yellow behind the red. |
subbed | "on" | "off" | A square with a diagonal arrow: green, bottom left to top right, for a player who came on; red, top right to bottom left, for one who was replaced. |
goals | number | A ball badge, with the count from two up. |
assists | number | A boot badge, with the count from two up. |
src | string | The photo, as a lazy-loaded <img>. A photo that fails to load falls back to initials. |
size | number | The photo's diameter in pixels. Leave it unset to scale with the pitch. |
children | ReactNode | Replaces the photo; see below. |
Any other HTML attribute (className, style, onClick, aria-*, data-*) goes on the avatar's
root <div>.
Formations
Avatars are placed by x and y, so a formation is a table of positions. Give each player a stable
key, pass a transition on left and top through className, and the avatars slide when the
shape changes. Use the arrows, the dots or the arrow keys:
"use client";import { useState } from "react";import { PlayerAvatar, VerticalPitch } from "@pitchkit/react";import type { PlayerCard, PlayerSubstitution } from "@pitchkit/react";import { docsAppearance } from "./docs-appearance";interface Player { number: number; name: string; photo: string; card?: PlayerCard; subbed?: PlayerSubstitution; goals?: number; assists?: number;}// The same fictional XI as the first example: invented names, and AI-generated// faces of people who don't exist.const squad: Player[] = [ { number: 1, name: "J. Marlow", photo: "marlow" }, { number: 3, name: "T. Brennick", photo: "brennick" }, { number: 25, name: "S. Okonkwo-Hale", photo: "okonkwo-hale", card: "yellow" }, { number: 14, name: "D. Varela-Ruiz", photo: "varela-ruiz", subbed: "off" }, { number: 2, name: "E. Lindqvist", photo: "lindqvist", card: "yellow", assists: 1 }, { number: 24, name: "A. Mbeki-Soto", photo: "mbeki-soto" }, { number: 6, name: "R. Tanaka-Holm", photo: "tanaka-holm", subbed: "off" }, { number: 8, name: "L. Delacroix", photo: "delacroix", card: "yellow", goals: 2 }, { number: 23, name: "N. Haddad-Kerr", photo: "haddad-kerr", subbed: "off" }, { number: 11, name: "F. Ostrander", photo: "ostrander", subbed: "on" }, { number: 10, name: "M. Quintero-Bell", photo: "quintero-bell", assists: 2 },];// Each formation places every shirt number, in StatsBomb coordinates (120 x 80).// On a vertical pitch x runs top to bottom, so the keeper at x 112 is at the// foot and the side attacks upwards; y 0 is the left touchline.type Spot = readonly [x: number, y: number];const formations: { name: string; spots: Record<number, Spot> }[] = [ { name: "4-4-2", spots: { 1: [113, 40], 3: [86, 13], 25: [96, 28], 14: [96, 52], 2: [86, 67], 23: [58, 13], 24: [70, 28], 6: [70, 52], 10: [58, 67], 11: [30, 29], 8: [30, 51], }, }, { name: "4-2-3-1", spots: { 1: [113, 40], 3: [86, 13], 25: [96, 28], 14: [96, 52], 2: [86, 67], 24: [76, 28], 6: [76, 52], 23: [52, 14], 8: [56, 40], 10: [52, 66], 11: [28, 40], }, }, { name: "4-3-3", spots: { 1: [113, 40], 3: [86, 13], 25: [96, 28], 14: [96, 52], 2: [86, 67], 24: [68, 22], 6: [78, 40], 8: [68, 58], 23: [38, 14], 11: [27, 40], 10: [38, 66], }, }, { name: "3-5-2", spots: { 1: [114, 40], 25: [92, 18], 6: [96, 40], 14: [92, 62], 3: [66, 12], 24: [72, 26], 8: [58, 40], 23: [72, 54], 2: [66, 68], 11: [30, 29], 10: [30, 51], }, },];const buttonClass = "size-8 rounded-md border border-fd-border bg-fd-card text-sm font-medium hover:bg-fd-accent";/** * One XI in four shapes. Each player keeps their `key` across formations, so * a transition on `left`/`top`, passed through `className`, slides every * avatar to its new spot. */export function PlayerAvatarFormationsBasic() { const [index, setIndex] = useState(0); const formation = formations[index]!; const go = (step: number) => setIndex((i) => (i + step + formations.length) % formations.length); return ( <div role="region" aria-roledescription="carousel" aria-label="Formations" className="mx-auto max-w-[420px]" onKeyDown={(e) => { if (e.key === "ArrowLeft") go(-1); if (e.key === "ArrowRight") go(1); }} > <div className="mb-3 flex items-center justify-between gap-3"> <button type="button" className={buttonClass} aria-label="Previous formation" onClick={() => go(-1)} > ‹ </button> <div className="flex flex-col items-center gap-1.5"> <span aria-live="polite" className="text-base font-semibold tabular-nums"> {formation.name} </span> <div className="flex gap-1.5"> {formations.map((f, i) => ( <button key={f.name} type="button" aria-label={f.name} aria-current={i === index} onClick={() => setIndex(i)} className={ i === index ? "size-2 rounded-full bg-fd-primary" : "size-2 rounded-full bg-fd-muted-foreground/40 hover:bg-fd-muted-foreground" } /> ))} </div> </div> <button type="button" className={buttonClass} aria-label="Next formation" onClick={() => go(1)} > › </button> </div> <VerticalPitch type="statsbomb" appearance={docsAppearance}> {squad.map((p) => { const [x, y] = formation.spots[p.number]!; return ( <PlayerAvatar key={p.number} x={x} y={y} name={p.name} number={p.number} card={p.card} subbed={p.subbed} goals={p.goals} assists={p.assists} src={`/players/${p.photo}.webp`} className="motion-safe:transition-[left,top] motion-safe:duration-500 motion-safe:ease-in-out" /> ); })} </VerticalPitch> </div> );}<PlayerAvatar
key={p.number}
x={spots[p.number][0]}
y={spots[p.number][1]}
name={p.name}
className="motion-safe:transition-[left,top] motion-safe:duration-500"
/>Optimised images
src is a plain <img loading="lazy" decoding="async">, which is fine for a CDN that already
serves small images. To use your framework's image component, pass it as children instead. It
renders inside a round box the size of the photo, with position: relative, so next/image's fill
mode works as is:
import Image from "next/image";
<PlayerAvatar x={p.x} y={p.y} name={p.name} number={p.number}>
<Image src={p.photoUrl} alt="" fill sizes="64px" style={{ objectFit: "cover" }} />
</PlayerAvatar>;The same works for <picture>, Cloudinary or Imgix components, or anything else. Use alt="": the
player's name is already in the avatar as text.
Size
With no size, the photo is a tenth of the pitch's shorter side, between 24 and 72 pixels, so a
full lineup fits in either orientation and shrinks with the pitch. The badges and the name scale with
it. To fix it everywhere, set --pitch-avatar-size in CSS; size beats the variable.
.lineup {
--pitch-avatar-size: 56px;
}Rendering
The avatars are HTML, not SVG, so the photo can be any image component. They render into a layer
<Pitch> stacks over its <svg>, positioned in percentages of the pitch, so they move with it when
it resizes. Like the density layers, they are not in the server-rendered
HTML and appear after hydration.
- With a
crop, an avatar whose point is outside the shown part of the pitch isn't drawn, as an SVG mark there would be clipped. One near a touchline still shows in full, label and all. - Only the photo and the name take the pointer, so a
<Scatter>tooltip under the space around an avatar still works. styleandclassNamego on the avatar's root, but can't move it: its position and centring always win. Atransforminstyle, such asscale(1.1), is applied after the centring.
Styling
| Variable | Controls | Default |
|---|---|---|
--pitch-avatar-size | Photo diameter | Scales with the pitch |
--pitch-avatar-bg | Behind the photo and the initials | #d4d8dd |
--pitch-avatar-ring | The ring around the photo | #ffffff |
--pitch-avatar-text | The name | #ffffff |
--pitch-avatar-muted | The shirt number | rgba(255, 255, 255, 0.7) |
--pitch-avatar-icon | The goal and assist icons and counts | #111827 |
--pitch-avatar-icon-halo | The thin edge that lifts icons off the grass | rgba(255, 255, 255, 0.9) |
--pitch-avatar-ball | The ball's light panels | #ffffff |
--pitch-avatar-initials | The initials when there is no photo | #1f2937 |
--pitch-sub-on | The square, for a player who came on | #22c55e |
--pitch-sub-off | The square, for a player who went off | #ef4444 |
--pitch-sub-arrow | The arrow inside the square | #ffffff |
--pitch-card-yellow | Yellow cards, shared with the charts | #facc15 |
--pitch-card-red | Red cards, shared with the charts | #ef4444 |
Each part carries a data-pitchkit-part attribute (avatar-photo, avatar-initials,
avatar-card, avatar-sub, avatar-goals, avatar-assists, avatar-label, avatar-number,
avatar-name) for Tailwind's arbitrary variants.
Accessibility
The badges are hidden from screen readers and said in words instead, after the name: "8 L. Delacroix, 2 goals, yellow card, substituted off".