Competitor Card

A competitor profile card. It renders the competitor's identity, their season statistics with a competition switcher, recent form, the next or last fixture (with betting odds, or a live badge while it is in progress), and two inline panels opened from the header. Four templates — standard, hero, horizontal, compact — change the arrangement and density, never the behaviour.

entityId accepts either kind of competitor, and the card adapts:

  • a team (fb:t:…) shows the club's season — points, W/D/L, goals — and its second header CTA opens the Squad;
  • an athlete (fb:p:…) shows the player's season — goals, minutes, appearances — and its second CTA opens a Season stats table. See Athletes.

Import

import { CompetitorCard } from "fansunited-frontend-components";
import { CompetitorCardProps } from "fansunited-frontend-core";

Required props

PropTypeDescription
entityIdstringCompetitor ID to render, e.g. "fb:t:8205" for a team or "fb:p:48824" for an athlete. An id that resolves to nothing, or to an entity that is not a competitor, renders the not-found state.
sdkFansUnitedSDKModelSDK instance.
languageLanguageTypeDisplay language.

Optional props

PropTypeDescription
themeOptionsCustomThemeOptionsSee Theming.
templateCompetitorCardTemplateType"standard" | "hero" | "horizontal" | "compact". Defaults to "standard". See Templates.
showCountrybooleanPut the competitor's country in the header subtitle — Spain • Est. 1899 for a club, the last part of Forward · Barcelona · 29 yrs · Brazil for a player. Defaults to true.
defaultImagePlaceholderUrlstringFallback image when the entity carries no crest or headshot.
headerLayout"horizontal" | "stacked"Stack the crest above the name and centre the header. Defaults per template.
ctaLayout"inline" | "stacked" | "responsive"Where the header's two buttons sit: beside the identity, or on their own row with the two buttons running vertically. "responsive" stacks them in a narrow container. Defaults to "responsive". Two templates differ: horizontal always stacks, since its buttons live on a narrow vertical rail, and compact treats "responsive" as "inline" because two icon squares fit beside the identity at any width — an explicit "stacked" still applies, and moves the pair to its own row at the trailing edge rather than running the two icons vertically.
titlePositionCompetitorCardTitleAlign"left" | "center" | "right". Alignment for every section title, including the fixtures panel's. Defaults to "left". A section header often carries a control beside its title — the competition switcher, the winning-streak caption, the Last | Next toggle — and the alignment moves it: "center" centres the title on the row with the control at the end, and "right" takes the title to the trailing edge and drops the control onto its own row beneath.
showCompetitionSwitcherbooleanRender the competition switcher, the card's single scope control. Defaults to true. See Statistics.
showStatsbooleanRender the season-statistics section. Defaults to true.
statsCompetitorStatsConfigThe points tile's background. See Statistics.
showFormbooleanRender the recent-form strip. Defaults to true.
formCompetitorFormConfigStrip length and row CTA. See Recent form.
showFeaturedMatchbooleanRender the featured next/last fixture. Defaults to true.
featuredMatchCompetitorFeaturedMatchConfigWhich fixture, the Last | Next toggle, and the row CTA. See Featured match.
showOddsbooleanRender betting odds inside the featured match. Defaults to true. Only ever shown for an upcoming fixture, and only when the odds API answers with prices for it.
oddsCompetitorOddsConfigDisplay mode, poll interval and operator filter. See Betting odds.
showFixturesbooleanRender the Fixtures header CTA and its inline panel. Defaults to true.
fixturesCompetitorFixturesConfigRow CTA for the fixtures panel.
showSquadbooleanRender the Squad header CTA and its inline panel. Defaults to true. Teams only — inert on an athlete.
squadCompetitorSquadConfigPlayer CTA and the expanded card's background. See Squad.
showSeasonStatsbooleanRender the Season stats header CTA and its inline table. Defaults to true. Athletes only — inert on a team. See Athletes.
hasBrandingbooleanPaint the branded surfaces with the competitor's own colours — an athlete's card uses their club's. Defaults to true; false falls back to the theme accent, which is also what happens when the entity declares no colours. See Branding.
headerBackgroundGradientstringCSS background for the branded header (hero and horizontal). Overrides the competitor's colours.
liveRefreshbooleanPoll while one of the competitor's fixtures is LIVE, so the score and minute stay current. Defaults to true. Nothing is polled when no fixture is live.
refreshIntervalnumberLive-refresh poll interval in milliseconds. Defaults to 30000 (30s).
labelsCompetitorCardLabelsOverrides for any user-facing string. See Labels.
onAddToBetslip(selection) => voidCalled when a visitor clicks an odds price. See Betting odds.

Templates

TemplateHeaderTeam statisticsAthlete statisticsFormFeatured match
standardWashed strip, labelled CTAsPoints tile + W/D/L bar + goal barsGoals tile + minutes and appearances bars + stat chipsScore tiles, 5 per rowStacked crests, large score
heroBranded gradient carrying the CTAs, headline tiles and the formW/D/L bar + goal bars (the header already has points, win rate and GD)Bars + chips (the header already has goals, assists, apps and minutes)In the header, as outcome dotsStacked crests, large score
horizontalPersistent branded rail with the CTAs and the competition switcherPoints tile + bars, narrowerBars + chips (the rail carries the headline tiles)Score tilesStacked crests, large score
compactIcon-only CTAsOne row of eight cells (P W D L GF GA GD Pts) over a thin barOne row of five cells (Apps Goals Assists Min G+A/90)Outcome dotsSingle line

Every template shows the same data and every control behaves identically; only the arrangement changes.

Athletes

An athlete's card is the same card with the statistics swapped. What changes:

  • Statistics are the player's season: a headline goals tile with a per-90 rate, a minutes-played bar against the minutes available, an appearances bar split into starts and substitute appearances, and stat chips (assists, goal contributions per 90, minutes per goal, penalties, cards). A goalkeeper gets clean sheets, goals conceded and a per-game average instead — goals and assists say nothing about a keeper's season.
  • The second header CTA opens Season stats rather than Squad: a table of every statistic the feed carries, one column per competition plus a total. It is the one section the competition switcher does not filter — the comparison across competitions is the point of it.
  • Form, the featured match and the fixtures panel are the player's club's matches. A player has no fixtures of their own, so the caption beside the form title names the club (Barcelona results · latest → oldest).
  • The header shows the headshot with the club crest on its corner, and a subtitle of position · club · age · country.
  • Branded surfaces take the club's colours, since a player declares none. hasBranding and headerBackgroundGradient work exactly as they do for a team.

The club is read from the player's own statistics — the team they logged the most minutes for that season, so a mid-season transfer follows them to the new club.

Appearance and minute totals are measured against the club's matches in the same competition and season: 7 of 11 appearances, 567 of 990 minutes. Minutes can exceed the total, because stoppage time counts — 479 of 450 is the feed being accurate, not a miscount — and the bar stops at full.

A player with no resolvable club renders their statistics alone: there are no fixtures to show, so those sections come off rather than rendering empty.

Branding

The competitor's own colours paint four surfaces: the branded header (hero and horizontal), the headline tile, the featured-match panel and the expanded player card.

Some clubs declare a colour that is the card background — Real Madrid's primary is #ffffff. Two rules keep those readable:

  • Solid fills (the goals-scored bar, the player-card bars, the "Full profile" button) need to be told apart from the surface behind them. The competitor's palette is walked primary → secondary → tertiary and the first colour that stands out is used; a competitor whose whole palette collides with the surface falls back to the theme accent. Real Madrid's bars come out gold. "Stands out" is lightness or hue, so Borussia Dortmund's yellow survives on a white card even though its luminance contrast is 1.31:1.
  • Text on a branded surface is white, as the design draws it, and flips to dark only where white would be unreadable — Manchester City's sky blue keeps white, Real Madrid's white-to-gold does not. A backgroundGradient you pass is a CSS string the widget cannot measure, so that keeps the theme's on-accent colour.
  • Branded gradients drop any stop that collides with the card — Tottenham's white-to-navy becomes a navy header, Real Madrid's gold-to-white a gold one — and collapse to a single colour in the rare case that no text colour reads across the remaining pair. Clubs whose two colours are both usable keep their gradient: Napoli stays navy-to-sky, Sevilla gold-to-red.

Both are resolved against the current surface, so a competitor renders differently in light and dark mode — a black-kit club is the same problem inverted.

Statistics

stats?: {
  backgroundGradient?: string;    // points tile; default = competitor branding
}

The competition switcher (All, then the domestic league, then the rest) is the card's single scope control: it filters the statistics, the form, the featured match, the fixtures panel and the squad panel together. The card opens on the competitor's domestic league rather than on All. It is drawn on the statistics title row, but it belongs to the whole card, so it is switched with the top-level showCompetitionSwitcher rather than a stats setting. A competitor with a single competition renders none either way.

Friendlies (fb:c:224) are excluded from every section.

The card covers one season — the season the statistics are for. The feed's fixture list can reach back into the previous one, and those matches are left out: a cup run that finished last May would otherwise sit in the recent form, add a competition to the switcher with no statistics behind it, and count towards an athlete's appearance total. A competition with no statistics of its own is still shown while it belongs to the current season or still has a fixture to come.

A cup is a bracket and awards no table points, so while one is in scope every points figure comes off — the points tile, the points-per-game average and the compact row's Pts cell. Everything else (played, W/D/L, goals, goal difference, win rate) still applies. All keeps its points: the league games inside it earned real ones.

Recent form

form?: {
  maxItems?: number;               // default 5, clamped to 3–10
  matchCTA?: CompetitorCardLinkCTA;
}

Latest fixture first. Each item shows the score from the competitor's own side (3-1 whether they were home or away); hovering shows the full home-first scoreline, both crests, the competition and the date. A winning streak is captioned beside the section title.

Featured match

featuredMatch?: {
  type?: "next" | "last";          // which side it opens on; default "next"
  toggle?: boolean;                // show the Last | Next toggle; default true
  backgroundGradient?: string;     // default = a soft wash of the competitor's colours
  matchCTA?: CompetitorCardLinkCTA;
}

A fixture in progress takes the section over regardless of type, and is the ONE place the card says a match is running: a red badge with a pulsing dot and the match minute sits above the score — the same badge the EventCard wears, so the two agree on one page. Where the clock cannot be placed (halftime, a shootout) the badge reads LIVE instead of a minute.

With nothing on the side type asks for, the other side fills in — a season that has finished shows its last result under a type: "next" card rather than dropping the section. The toggle only renders when both sides actually have a fixture, since one that landed back on what is already showing would read as a control that does nothing.

Betting odds

odds?: {
  mode?: "default" | "flash";  // "flash" alternates operator and prices
                              // default: "flash" in `compact`, "default" elsewhere
  flashInterval?: number;      // ms, default 3000
  pollingInterval?: number;    // ms, default 30000
  operators?: string[];        // restrict to these operator config ids
}

Full-time 1X2 prices for the upcoming featured match, from the first operator that has them. When the odds API returns nothing the strip simply does not render — there is no error state for a missing price.

onAddToBetslip receives { matchId, market: "FT_1X2", pick: "1" | "X" | "2", odd }. The widget owns no betslip, so pass this to push the selection into yours. With the Fans United Betslip, that is one call:

import { betslipApi } from "fansunited-frontend-components";

<CompetitorCard
  entityId="fb:t:8205"
  sdk={sdk}
  language="en"
  onAddToBetslip={({ matchId, market, pick }) =>
    betslipApi.setSelection(`${matchId}:${market}:${pick}`)
  }
/>

The operator's own link still opens either way.

Squad

squad?: {
  playerCTA?: CompetitorCardLinkCTA;
  backgroundGradient?: string;  // expanded player card
}

Players are grouped Goalkeepers / Defenders / Midfielders / Forwards and sorted by minutes played. A row expands into a card with the player's headline stat, how much of the season they played, their starts-vs-substitute split and their cards. Goalkeepers show clean sheets and goals conceded where outfield players show goals and assists.

With playerCTA set, a Full profile button appears inside the expanded card. The row itself always expands rather than navigating.

The squad is fetched the first time a visitor opens the panel.

Row CTAs

Every clickable row — a fixture, a form item, the featured match, a squad player — resolves through a CompetitorCardLinkCTA. The widget bakes in no URLs: a card with no CTAs configured has inert rows, with no hover state and no pointer cursor.

interface CompetitorCardLinkCTA {
  onClick?: () => void;
  url?: string | null;
  target?: LinkTargetType;
}

url is a template. Each row substitutes the placeholders it can fill, URL-encoded:

{matchId} {competitorId} {opponentId} {competitionId} {seasonId} {date} {athleteId}

A placeholder the row cannot fill resolves to an empty string rather than being left in the URL. On an athlete's card {competitorId} is the club the fixture belongs to, since that is whose match the row is.

<CompetitorCard
  entityId="fb:t:8205"
  sdk={sdk}
  language="en"
  fixtures={{ matchCTA: { url: "/football/match/{matchId}" } }}
  squad={{ playerCTA: { url: "/football/player/{athleteId}" } }}
/>

With url the row renders as an <a href target>, which keeps it crawlable and middle-clickable. With onClick as well, the handler runs and the navigation is prevented, so your router handles it.

Labels

Every user-facing string falls back to the locale bundle and can be overridden per key through labels. Keys carrying {{…}} are interpolated — keep the placeholder when overriding.

<CompetitorCard
  entityId="fb:t:8205"
  sdk={sdk}
  language="en"
  labels={{
    statsTitle: "This season",
    formTitle: "Last five",
    showAll: "See all {{count}}",
  }}
/>

See CompetitorCardLabels for the full key list.

Example

A club:

import { CompetitorCard } from "fansunited-frontend-components";

<CompetitorCard
  entityId="fb:t:8205"
  sdk={sdk}
  language="en"
  template="hero"
  form={{ maxItems: 8, matchCTA: { url: "/football/match/{matchId}" } }}
  featuredMatch={{ type: "next", matchCTA: { url: "/football/match/{matchId}" } }}
  fixtures={{ matchCTA: { url: "/football/match/{matchId}" } }}
  squad={{ playerCTA: { url: "/football/player/{athleteId}" } }}
/>

A player — the same props, minus the ones a player has no use for:

<CompetitorCard
  entityId="fb:p:48824"
  sdk={sdk}
  language="en"
  template="hero"
  form={{ maxItems: 8, matchCTA: { url: "/football/match/{matchId}" } }}
  featuredMatch={{ type: "next", matchCTA: { url: "/football/match/{matchId}" } }}
  fixtures={{ matchCTA: { url: "/football/match/{matchId}" } }}
/>

Did this page help you?