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. National sides count as teams and render the same way, with their country left out of the subtitle since it is already the name; - 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: a club or national side ("fb:t:8205", "fb:t:81770") or an athlete ("fb:p:48824"). 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 false. Only ever shown for an upcoming fixture, and only when the odds API answers with prices for it. |
odds | OddsConfig | Market, display mode, poll interval and operator preference. 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 (the feed counts stoppage time, so once the minutes reach that ceiling they stand alone rather than claiming "671 of 630"), 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.
Only the primary is read, and the rule is deliberately narrow: the club keeps its colour, and the text moves. Real Madrid's primary is #ffffff; their header stays white — white is what Real Madrid are — and the label on it goes dark. White text leads everywhere it is legible, so Manchester City's sky blue keeps the white the club itself puts on it (2.47:1), and gives way only where it genuinely cannot be read.
Two consequences follow:
- A solid fill — the goals-scored bar, the player-card bars, the "Full profile" button — sits on the card, so there is no text to move. A white bar on a white card is absent rather than subtle, so a primary that collides with the card falls back to the theme accent. It does not fall through to the club's other colours: painting Real Madrid gold reads as a different club, not as a fallback.
- A branded header whose colour is the card gets a hairline where it meets the body, so the card doesn't read as one undivided block.
Everything is 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 is the card's single scope control: it filters the statistics, the form, the featured match, the fixtures panel and the squad panel together. 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.
The card opens on the competition its own featured match is in — the live one, else the next fixture, else the last result — and that option is listed first, directly after All. With no fixtures at all it falls back to the competitor's domestic league. It never opens on All, which mixes a league table with a cup run.
Friendlies (fb:c:224) are excluded from every section.
Wins counted here include the ones the sport settles after regulation, in whichever way the feed spells them: hockey writes won_ot, football won_extratime, and both sit beside a separate shootout key. Reading won alone reports a played match with no result against it — a team whose only win came in overtime showed 0 W, 0 D, 0 L on 1 played, and a World Cup won in extra time read 8 played against 6 won and 1 drawn.
Totals that cannot be reconciled with the results printed above them are replaced by the results themselves: the card derives the season from its own fixtures whenever the feed covers fewer matches than it shows, or whenever the feed's own W/D/L does not add up to its played count.
Points follow the sport's own rule, not the widget's: football pays 3 for a win and 1 for a draw; ice hockey pays 2 for any win plus 1 for losing after regulation. A league that keeps no points table at all is what showPoints is for.
The scoring vocabulary follows the sport too. A sport scored in points rather than goals — American football, basketball, rugby — reads Points for / Points against / PF / PA / DIFF where football reads Scored / Conceded / GF / GA / GD; "conceded" is not a word American sports use. Those are six more labels keys (pointsFor, pointsAgainst, pointsForShort, pointsAgainstShort, pointsDifference, pointsDifferenceShort) and a client can override any of them.
The numbers behind those bars are read whichever way the feed states them. Football reports totals (goals_scored); American football reports only averages (avg_pts_scored over matches_played) and no total at all, so the total is recovered from the average — exactly, since the feed's two decimal places round back to the figure the fixtures add up to.
A result settled after regulation is marked wherever it appears — OT beside the score on a form tile and a fixtures row, a corner tag on a form dot, and "After overtime" in the form tooltip and under the featured match's score. The score itself stays the regulation one, because that is all the feed reports: a hockey tie arrives as 5-5 with winner_id: "DRAW" and only the status saying it went further, so the card marks the fixture rather than claiming a winner it was not told about.
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?: {
market?: OddsMarket; // default "FT_1X2" — see below
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[]; // preferred operator config ids
affiliateId?: string; // substituted into the operator URLs
affiliateIds?: Record<string, string>; // per-operator, wins over affiliateId
}The card shows one operator — the first that publishes a complete market — so operators is a preference order rather than a filter that widens the strip. Listing a second one lets it cover the first on a fixture the first has no prices for. Betslip's equivalent is the singular operatorId.
affiliateId is substituted into every operator URL carrying an {affiliate_id} placeholder, so a click from the strip is attributed to you — the same field as BetslipProps.affiliateId. Use affiliateIds when each operator has its own.
Outright prices for the upcoming featured match, from the first operator that has a complete set. Off by default — pass showOdds to render them.
market picks what is priced. The options are:
FT_1X2(the default)HT_1X2BOTH_TEAMS_SCOREOVER_GOALS_0_5…OVER_GOALS_6_5OVER_CORNERS_6_5…OVER_CORNERS_13_5
For FT_1X2, the market follows the sport, not the widget: a fixture that can be drawn is priced three ways (FT_1X2 — 1 · X · 2), one that cannot is priced two (WINNER_OT — 1 · 2). Football and ice hockey prefer the three-way market and fall back to the two-way; American football, basketball and tennis the other way round. Both are requested together, so one round trip settles it and a sport the API adds later still resolves to whichever it publishes.
An operator only qualifies with the market's whole set of outcomes — some publish the draw alone, or repeat a code — and the next operator is tried otherwise. When none has a complete market the strip simply does not render; there is no error state for a missing price.
onAddToBetslip receives { matchId, market, pick, odd, operatorId }:
marketis the code the price actually came from, for example"FT_1X2"or"WINNER_OT".pickis"1"/"X"/"2","YES"/"NO"or"OVER"/"UNDER". Pass the market through rather than assuming 1X2. 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"
showOdds
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 13 days ago
