# Player Avatar

Source: https://www.pitchkitjs.com/docs/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.

```tsx
import { PlayerAvatar, VerticalPitch } from "@pitchkit/react";
import type { PlayerCard, PlayerSubstitution } from "@pitchkit/react";

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

```tsx
<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:

```tsx
import { useState } from "react";
import { PlayerAvatar, VerticalPitch } from "@pitchkit/react";
import type { PlayerCard, PlayerSubstitution } from "@pitchkit/react";

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">
        {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>
  );
}
```

```tsx
<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:

```tsx
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.

```css
.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](/docs/guides/nextjs-ssr), 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

| 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](/docs/styling/tailwind).

## 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".
