Component

SmallMultiples

Responsive comparison grid for repeated football visuals, especially pitch-chart grids and bespoke analyst cells. It is an HTML layout primitive in @withqwerty/campos-react: stable keying, per-cell labels, and per-cell error isolation without pretending every repeated chart is its own bespoke layout.

Actual usage

Repeated pitch analysis first.

In practice, SmallMultiples is mostly used to repeat pitch charts or bespoke pitch cells across teams, matches, or players. Generic card dashboards are possible, but they are not the main football workflow this page should teach.

Repeated ShotMap gridsRepeated zone gridsRepeated PassFlow tilesRaw Pitch analyst cellsShared crop and view hints
Pressing waveArsenal12 shots, 1.8 xG
Transition attacksLiverpool9 shots, 1.4 xG
Box occupationInter10 shots, 1.3 xG
Wide releaseNapoli8 shots, 1.1 xG
Official path

Live repeated charts

ShotMap, PassMap, Heatmap, Territory, and PassFlow are the current official repeated-chart participants.

Escape hatch

Raw Pitch still matters

Use raw Pitch for bespoke analyst cells until a real use case proves PitchCell belongs.

Stories

Canonical behaviors.

Real football usage

Show the repeated pitch work before the utility mechanics.

This page should teach the real library pattern: repeated football visuals first, grid mechanics second. The official chart set is now broader, but still chart specific rather than magical.

Analyst-style Pitch Grid

This is the dense editorial pattern: external header copy, compact labels, and many tiny pitch cells in one grid.

Team Goal Kick Locations

English Premier League 2024-25 | Ending inside & outside the box

Spurs
32 GKs | 84.4% In Box
Southampton
56 GKs | 83.9% In Box
Brighton
44 GKs | 77.3% In Box
Chelsea
47 GKs | 76.6% In Box
Man Utd
46 GKs | 71.7% In Box
Liverpool
37 GKs | 84.9% In Box
Fulham
54 GKs | 64.8% In Box
Wolves
51 GKs | 62.7% In Box
Brentford
55 GKs | 60.0% In Box
Arsenal
45 GKs | 20.0% In Box
Repeated Shot Maps

ShotMap is the fully symmetric repeated-chart path today: it consumes pitchOrientation, pitchCrop, and sharedScale cleanly.

Pressing waveArsenal12 shots, 1.8 xG
Transition attacksLiverpool9 shots, 1.4 xG
Box occupationInter10 shots, 1.3 xG
Wide releaseNapoli8 shots, 1.1 xG
Repeated Pass Maps

PassMap is the second honest repeated-chart participant. It uses shared crop and scale, but chart-specific view forwarding is still selective rather than universal.

Pressing waveArsenal12 shots, 1.8 xG
Transition attacksLiverpool9 shots, 1.4 xG
Box occupationInter10 shots, 1.3 xG
Wide releaseNapoli8 shots, 1.1 xG
Repeated Heatmaps

Heatmap is now an honest repeated-grid participant when the bins stay coarse or tactical and the scale bar is suppressed per cell.

Pressing waveArsenal12 shots, 1.8 xG
Transition attacksLiverpool9 shots, 1.4 xG
Box occupationInter10 shots, 1.3 xG
Wide releaseNapoli8 shots, 1.1 xG
Repeated Territory Cards

Territory is the editorial repeated-zone path: fast-to-read cards, horizontal orientation, and no need to invent a shared-domain story.

Pressing waveArsenal12 shots, 1.8 xG
29%14%43%14%
Transition attacksLiverpool9 shots, 1.4 xG
14%14%14%14%14%29%
Box occupationInter10 shots, 1.3 xG
14%29%14%29%14%
Wide releaseNapoli8 shots, 1.1 xG
14%43%14%29%
Repeated PassFlow Tiles

PassFlow works in repeated tactical grids when the bins stay coarse and the header and legend chrome are removed.

Pressing waveArsenal12 shots, 1.8 xG
Transition attacksLiverpool9 shots, 1.4 xG
Box occupationInter10 shots, 1.3 xG
Wide releaseNapoli8 shots, 1.1 xG
Grid mechanics

Keep the utility stories, but make them football-shaped.

Responsive layout, error isolation, and empty states still matter. They just should not dominate the page over actual pitch usage.

Custom Empty State

Empty and null inputs are a first-class contract, not a consumer-side conditional.

No comparison set selected.Choose a shortlist of matches or teams before rendering the grid.
Responsive minCellWidth

Use auto-fill with a minimum cell width when the entity count and viewport vary widely. This should still feel like repeated pitch analysis, not a generic card wall.

Pressing waveArsenal12 shots, 1.8 xG
Transition attacksLiverpool9 shots, 1.4 xG
Box occupationInter10 shots, 1.3 xG
Wide releaseNapoli8 shots, 1.1 xG
Cross-cutting

Shared concerns.

Current participation

SmallMultiples forwards a shared view object, but repeated-chart adoption is chart-specific. Do not assume every pitch chart consumes the full hint set.

Surface Status Reads from view today Guidance
ShotMap Live pitchOrientation, pitchCrop, sharedScale Use for the fully aligned repeated-chart path.
PassMap Live pitchCrop, sharedScale Use when repeated raw trajectories matter more than aggregation.
Heatmap Live pitchOrientation → attackingDirection, pitchCrop Use for repeated discrete zone-density reads when each cell should keep inspectable bins.
Territory Live pitchOrientation → attackingDirection, pitchCrop Use for repeated editorial zone cards when readability matters more than exact bin counts.
PassFlow Live pitchOrientation → attackingDirection, pitchCrop Use for repeated tactical flow tiles with coarse bins and standalone-page chrome suppressed.
KDE Deferred pitchCrop only Keep standalone unless a real repeated-density use case proves it belongs.
PassNetwork Not part of the immediate promise Chart-owned attackingDirection only Do not market as a dense-grid default unless a battle-test proves it belongs.
Pitch / queued PitchCell Raw path live / wrapper queued Whatever you forward manually Use raw Pitch for bespoke analyst cells; PitchCell stays queued until repeated charts or raw Pitch prove too awkward.

Use repeated charts

Choose this when the same football question repeats cleanly across entities: shots, raw pass paths, discrete zones, editorial territory, or coarse pass flow.

Use raw Pitch

Choose raw Pitch when you need mixed marks, custom bins, or editorial analyst cells that do not honestly map to one shipped chart.

Do not wait for magic

PitchCell is still queued. Do not block a real analyst grid on a wrapper that does not yet have proven reuse.

Usage

Best-practice examples.

Smallest good football usage

Start with a repeated pitch chart, not a generic card grid. This is the smallest honest ShotMap-based use.

                    import { CellLabel, SmallMultiples, ShotMap } from "@withqwerty/campos-react";

export function ShotComparison({ teams }) {
  return (
    <SmallMultiples
      items={teams}
      getItemKey={(team) => team.id}
      pitchOrientation="horizontal"
      pitchCrop="full"
      columns={{ minCellWidth: 220 }}
      renderLabel={(team) => (
        <CellLabel title={team.name} eyebrow={team.phase} caption={team.summary} />
      )}
      renderCell={(team, _index, view) => (
        <ShotMap
          shots={team.shots}
          orientation={view.pitchOrientation}
          crop={view.pitchCrop}
          showHeaderStats={false}
          showLegend={false}
          showSizeScale={false}
        />
      )}
    />
  );
}
                  
Shared ShotMap scale

When cross-cell comparison matters, compute a shared size domain once and forward it into each chart.

                    import {
  CellLabel,
  SmallMultiples,
  ShotMap,
  computeSharedPitchScale,
} from "@withqwerty/campos-react";

export function ShotComparison({ teams }) {
  const sharedScale = computeSharedPitchScale(teams, {
    size: (team) => team.shots.map((shot) => shot.xg ?? 0),
  });

  return (
    <SmallMultiples
      items={teams}
      getItemKey={(team) => team.id}
      pitchOrientation="horizontal"
      pitchCrop="full"
      columns={{ minCellWidth: 180 }}
      sharedScale={sharedScale}
      renderLabel={(team) => (
        <CellLabel title={team.name} eyebrow={team.phase} caption={team.summary} />
      )}
      renderCell={(team, _index, view) => (
        <ShotMap
          shots={team.shots}
          orientation={view.pitchOrientation}
          crop={view.pitchCrop}
          sharedScale={view.sharedScale}
          showHeaderStats={false}
          showLegend={false}
          showSizeScale={false}
        />
      )}
    />
  );
}
                  
PassMap repeated grid

PassMap is a real repeated-chart participant, but not a symmetric one. It consumes shared crop and scale today; it does not read SmallMultiples' pitchOrientation hint.

                    import {
  CellLabel,
  PassMap,
  SmallMultiples,
  computeSharedPitchScale,
} from "@withqwerty/campos-react";

function scalePassWidth(
  value: number,
  domain: readonly [number, number] | undefined,
) {
  if (domain == null || domain[0] === domain[1]) return 0.7;
  const ratio = Math.max(0, Math.min(1, (value - domain[0]) / (domain[1] - domain[0])));
  return 0.35 + ratio * 0.7;
}

export function SharedPassGrid({ teams }) {
  const sharedScale = computeSharedPitchScale(teams, {
    width: (team) => team.passes.map((pass) => pass.length ?? 0),
  });

  return (
    <SmallMultiples
      items={teams}
      getItemKey={(team) => team.id}
      pitchCrop="full"
      sharedScale={sharedScale}
      renderLabel={(team) => (
        <CellLabel title={team.name} caption={team.summary} />
      )}
      renderCell={(team, _index, view) => (
        <PassMap
          passes={team.passes}
          crop={view.pitchCrop}
          sharedScale={view.sharedScale}
          showHeaderStats={false}
          showLegend={false}
          lines={{
            strokeWidth: ({ pass, sharedScale }) =>
              scalePassWidth(pass.length ?? 0, sharedScale?.widthDomain),
          }}
        />
      )}
    />
  );
}
                  
Charts that use attackingDirection

Heatmap, Territory, and PassFlow still fit the SmallMultiples story. They just need an explicit mapping from pitchOrientation to the chart's attackingDirection prop.

                    import { Heatmap, SmallMultiples } from "@withqwerty/campos-react";

function attackingDirectionFromView(view) {
  return view.pitchOrientation === "vertical" ? "up" : "right";
}

export function ZoneComparison({ teams }) {
  return (
    <SmallMultiples
      items={teams}
      getItemKey={(team) => team.id}
      pitchOrientation="horizontal"
      pitchCrop="full"
      renderCell={(team, _index, view) => {
        const attackingDirection = attackingDirectionFromView(view);

        return (
          <Heatmap
            events={team.passOrigins}
            zonePreset="5x3"
            crop={view.pitchCrop}
            attackingDirection={attackingDirection}
            showScaleBar={false}
          />
        );
      }}
    />
  );
}

// The same mapping pattern works for Territory and PassFlow too.
                  
Analyst-style pitch grid

Use raw Pitch when the analyst view is bespoke and should not be forced through a chart contract.

                    import { SmallMultiples } from "@withqwerty/campos-react";
import { Pitch } from "@withqwerty/campos-stadia";

export function GoalKickLocations({ teams }) {
  return (
    <section>
      <header>
        <h2>Team Goal Kick Locations</h2>
        <p>Ending inside & outside the box</p>
      </header>
      <SmallMultiples
        items={teams}
        columns={5}
        gap={12}
        pitchOrientation="horizontal"
        pitchCrop="full"
        getItemKey={(team) => team.id}
        renderLabel={(team) => (
          <div>
            <strong>{team.name}</strong>
            <span>{team.summary}</span>
          </div>
        )}
        renderCell={(team) => (
          <Pitch crop="full" attackingDirection="right" interactive={false}>
            {({ project }) => (
              <g>
                {team.points.map((point, index) => {
                  const p = project(point.x, point.y);
                  return (
                    <circle
                      key={index}
                      cx={p.x}
                      cy={p.y}
                      r={1.1}
                      fill={point.zone === "inside" ? "#8b5cf6" : "#fb7185"}
                      opacity={0.64}
                    />
                  );
                })}
              </g>
            )}
          </Pitch>
        )}
      />
    </section>
  );
}
                  
API

Public surface.

Prop Type Default Description
items ReadonlyArray<T> | null | undefined required Source rows for the grid. Null and empty both render the empty-state contract.
getItemKey (item: T, index: number) => string | number required Stable keying for per-cell recovery. Duplicate keys are warned in development.
renderCell (item: T, index: number, view: SmallMultiplesView) => ReactNode required Render the cell body. The third arg is the forward-compatible shared-view contract.
renderLabel (item: T, index: number) => ReactNode Optional label renderer for figcaption content above or below each cell.
columns number | { minCellWidth: number } { minCellWidth: 240 } Fixed count or responsive auto-fill layout.
labelPlacement "above" | "below" "above" Controls whether figcaptions sit before or after the cell body.
emptyState ReactNode "No items to compare." Override the generic empty state when no items are present.
pitchOrientation "horizontal" | "vertical" Optional shared pitch-view hint forwarded into renderCell via the third view argument.
pitchCrop "full" | "half" Optional shared crop hint forwarded into renderCell for participating pitch charts.
sharedScale SharedPitchScale Optional shared numeric domains produced by computeSharedPitchScale() and forwarded into renderCell.
CellLabel

Label primitive.

Prop Type Default Description
title ReactNode required Primary label content. Usually the compared entity name.
eyebrow ReactNode Optional small uppercase line above the title.
caption ReactNode Optional supporting line under the title.
Use with AI
LLM Prompt
Create a React component using Campos SmallMultiples. Import SmallMultiples and CellLabel from @withqwerty/campos-react, keep data shaping outside the component, use the final renderCell(item, index, view) signature, and show one repeated pitch-chart grid plus one bespoke raw Pitch analyst-grid example.