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
| Prop | Type | Description |
|---|---|---|
entityId | string | Competitor 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. |
sdk | FansUnitedSDKModel | SDK instance. |
language | LanguageType | Display language. |
Optional props
| Prop | Type | Description |
|---|---|---|
themeOptions | CustomThemeOptions | See Theming. |
template | CompetitorCardTemplateType | "standard" | "hero" | "horizontal" | "compact". Defaults to "standard". See Templates. |
showCountry | boolean | Put 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. |
defaultImagePlaceholderUrl | string | Fallback 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. |
titlePosition | CompetitorCardTitleAlign | "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. |
showCompetitionSwitcher | boolean | Render the competition switcher, the card's single scope control. Defaults to true. See Statistics. |
showStats | boolean | Render the season-statistics section. Defaults to true. |
stats | CompetitorStatsConfig | The points tile's background. See Statistics. |
showForm | boolean | Render the recent-form strip. Defaults to true. |
form | CompetitorFormConfig | Strip length and row CTA. See Recent form. |
showFeaturedMatch | boolean | Render the featured next/last fixture. Defaults to true. |
featuredMatch | CompetitorFeaturedMatchConfig | Which fixture, the Last | Next toggle, and the row CTA. See Featured match. |
showOdds | boolean | Render 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. |
odds | CompetitorOddsConfig | Display mode, poll interval and operator filter. See Betting odds. |
showFixtures | boolean | Render the Fixtures header CTA and its inline panel. Defaults to true. |
fixtures | CompetitorFixturesConfig | Row CTA for the fixtures panel. |
showSquad | boolean | Render the Squad header CTA and its inline panel. Defaults to true. Teams only — inert on an athlete. |
squad | CompetitorSquadConfig | Player CTA and the expanded card's background. See Squad. |
showSeasonStats | boolean | Render the Season stats header CTA and its inline table. Defaults to true. Athletes only — inert on a team. See Athletes. |
hasBranding | boolean | Paint 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. |
headerBackgroundGradient | string | CSS background for the branded header (hero and horizontal). Overrides the competitor's colours. |
liveRefresh | boolean | Poll 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. |
refreshInterval | number | Live-refresh poll interval in milliseconds. Defaults to 30000 (30s). |
labels | CompetitorCardLabels | Overrides for any user-facing string. See Labels. |
onAddToBetslip | (selection) => void | Called when a visitor clicks an odds price. See Betting odds. |
Templates
| Template | Header | Team statistics | Athlete statistics | Form | Featured match |
|---|---|---|---|---|---|
standard | Washed strip, labelled CTAs | Points tile + W/D/L bar + goal bars | Goals tile + minutes and appearances bars + stat chips | Score tiles, 5 per row | Stacked crests, large score |
hero | Branded gradient carrying the CTAs, headline tiles and the form | W/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 dots | Stacked crests, large score |
horizontal | Persistent branded rail with the CTAs and the competition switcher | Points tile + bars, narrower | Bars + chips (the rail carries the headline tiles) | Score tiles | Stacked crests, large score |
compact | Icon-only CTAs | One row of eight cells (P W D L GF GA GD Pts) over a thin bar | One row of five cells (Apps Goals Assists Min G+A/90) | Outcome dots | Single 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.
hasBrandingandheaderBackgroundGradientwork 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
backgroundGradientyou 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}" } }}
/>Updated about 2 hours ago
