PitchKit
Overlays

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>
PropTypeDoes
x, ynumberRequired. Where the photo's centre sits, in the pitch's type units.
namestringRequired. Shown under the photo, and the initials when there is no photo.
numbernumber | stringThe 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.
goalsnumberA ball badge, with the count from two up.
assistsnumberA boot badge, with the count from two up.
srcstringThe photo, as a lazy-loaded <img>. A photo that fails to load falls back to initials.
sizenumberThe photo's diameter in pixels. Leave it unset to scale with the pitch.
childrenReactNodeReplaces 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:

4-4-2
"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.
  • style and className go on the avatar's root, but can't move it: its position and centring always win. A transform in style, such as scale(1.1), is applied after the centring.

Styling

VariableControlsDefault
--pitch-avatar-sizePhoto diameterScales with the pitch
--pitch-avatar-bgBehind the photo and the initials#d4d8dd
--pitch-avatar-ringThe ring around the photo#ffffff
--pitch-avatar-textThe name#ffffff
--pitch-avatar-mutedThe shirt numberrgba(255, 255, 255, 0.7)
--pitch-avatar-iconThe goal and assist icons and counts#111827
--pitch-avatar-icon-haloThe thin edge that lifts icons off the grassrgba(255, 255, 255, 0.9)
--pitch-avatar-ballThe ball's light panels#ffffff
--pitch-avatar-initialsThe initials when there is no photo#1f2937
--pitch-sub-onThe square, for a player who came on#22c55e
--pitch-sub-offThe square, for a player who went off#ef4444
--pitch-sub-arrowThe arrow inside the square#ffffff
--pitch-card-yellowYellow cards, shared with the charts#facc15
--pitch-card-redRed 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".

On this page