StatsHub Docs
UI

Icons and imagery

Every filter option and every row of data carries a crest, a headshot or a symbol — and what to do for the ones that cannot.

A filter option or a data row that is only text makes the reader parse a name before they can act on it. "Bundesliga", "2. Bundesliga" and "Bundesliga 3" are three seconds of reading; three crests are one glance. This matters most on the surfaces that are lists of near-identical strings — a league rail, a bookmaker allow-list, a table of players — which is most of the product.

So: carry a visual on every filter option and every data row, and fall back through a chain rather than to nothing.

The fallback chain

FilterOptionVisual in apps/statshub-web/src/components/reusable/filter-controls.tsx implements it, and every set-valued filter control goes through it. Four steps, in order:

StepRendersWhen
1option.imageUrl in an AvatarThe entity has a real picture — a crest, a headshot, a bookmaker logo
2The label's initials, as the AvatarFallbackThe image 404s or has not loaded
3option.symbol, an explicit LucideIconThe option is a concept, and you know which symbol it is
4filterSymbol(value, label, context)Nothing was supplied — resolve one from the words

Only when all four come back empty does the row render text alone.

the option shape
export type FilterOption<T extends string | number = string> = {
  value: T;
  label: string;
  /** Crest or headshot. Falls back to the label's initials. */
  imageUrl?: string;
  /** Overrides the semantic symbol resolved from the value and label. */
  symbol?: LucideIcon;
  badge?: React.ReactNode;
  disabled?: boolean;
};

Passing neither imageUrl nor symbol is the normal case, not a lapse — step 4 covers most options without the call site doing anything.

Where the pictures come from

StatsHub entity artwork is stored in the R2 bucket and addressed by id. Use one of these paths instead of inventing a substitute icon:

`${R2_STORAGE_URL}/team/${teamId}.png`;
`${R2_STORAGE_URL}/player/${playerId}.png`;
`${R2_STORAGE_URL}/unique-tournament/${uniqueTournamentId}.png`;

R2_STORAGE_URL and getBookmakerLogo are exported from apps/statshub-web/src/lib/constants. Bookmakers go through the helper because their files are keyed by name rather than id.

Use uniqueTournamentId, not id

A league has both, and they differ. id is the seasonal row; the artwork is keyed by the competition. Getting this wrong yields a broken image for every league in the rail, which the initials fallback then quietly papers over — so it looks like a styling bug rather than a wrong id.

When there is no picture, resolve a symbol

apps/statshub-web/src/config/symbols.ts maps the product's vocabulary onto Lucide icons. filterSymbol takes any number of candidate strings and tries each against three tables in turn:

  1. METRIC_SYMBOLS — shots, tackles, corners, cards, and their basketball equivalents.
  2. CONCEPT_SYMBOLS — odds, hit rate, edge, bookmaker, lineup, venue.
  3. FILTER_HINTS — regex patterns over the label's words, for the long tail.
// Nothing passed: the symbol is resolved from the value and label.
<FilterToggleGroup id="stat-types" options={statTypes}  />

// A concept the tables do not know. Name it rather than leaving it bare.
{ value: "supersub", label: "Supersub hit rates", symbol: Armchair }

Adding a term to the tables is better than passing symbol at one call site: the tables are shared by the filter controls and the metric columns, so one entry covers both.

Non-photographic glyphs use a token, not an avatar

A yellow card, a position abbreviation, a market's initial — these are not pictures of an entity and should not sit in a round Avatar. Use FilterItemToken from @statshub/ui-web/components/filter-panel: a fixed 28px square, so a row carrying a card swatch and a row carrying GK keep their labels on the same left edge.

What has no visual, and stays that way

Not everything earns one, and a wrong icon is worse than none — it asserts a category the reader then has to un-learn.

  • Booleans. A switch is its own indicator; an icon beside it adds a second thing to read for one piece of state.
  • Ranges and thresholds. "1.30 – 10.00" is already a picture of itself.
  • Free text. The search field has a magnifier; its value has no symbol.
  • Ordinal positions. A rank column is numbers, and numbering them twice helps nobody.

If filterSymbol returns nothing for a term that clearly has a symbol, add it to the table. If it returns nothing because the term genuinely has none, that is the correct answer — do not reach for a generic circle to fill the space.

Data rows follow the same rule

The convention is not filter-specific. A table row, a result card and a list item name the same entities, so they carry the same crest or headshot, resolved the same way. AvatarWithFallback and PlayerAvatar in @statshub/ui-web exist for the rows that are not filter options.

Two consequences worth stating:

  • alt is empty on a decorative crest. The team's name is already in the row; a screen reader announcing "Arsenal, Arsenal" is worse than silence. Where the image is the only identifier — a crest with no label beside it — it takes a real alt.
  • Size comes from the row, not the image. size-5 in a filter row, size-6 in a table cell. An image that sets its own dimensions makes the row height depend on whether the picture loaded.

On this page