Match Highlight
Detailed match header for a match page, teams, score, live clock, cards, and corners.
"use client"import { useEffect, useState } from "react"import type { Match } from "@mrdoge/protocol"import { MatchHighlight } from "@/registry/mrdoge-ui/match-highlight/match-highlight"import { matchToMatchHighlightProps, matchesToCompetitionMatches } from "@/lib/mrdoge-adapters/match-highlight"import { getMrDogeClient } from "@/registry/mrdoge-ui/mrdoge-client/mrdoge-client"import { useMatch } from "@/registry/mrdoge-ui/use-match/use-match"import { useLiveMatch } from "@/registry/mrdoge-ui/use-live-match/use-live-match"import { useSharedUpcomingMatchId, useSharedLiveOrUpcomingMatchId,} from "@/components/docs/demos/use-shared-demo-matches"import { FINISHED_MATCH_ID } from "@/components/docs/sample-data"// Lazily fetches other matches today in the same competition, only once// the dropdown is actually opened, not eagerly on every render.function useCompetitionMatches(competitionId: number | undefined, date: string | undefined) { const [matches, setMatches] = useState<Match[] | null | undefined>(undefined) const [enabled, setEnabled] = useState(false) useEffect(() => { if (!enabled || !competitionId || !date) return let cancelled = false getMrDogeClient() .matches.list({ competitionIds: [competitionId], date, status: ["upcoming", "live", "completed"], limit: 100 }) .then((result) => { if (!cancelled) setMatches(result.data) }) .catch(() => { if (!cancelled) setMatches(null) }) return () => { cancelled = true } }, [enabled, competitionId, date]) return { matches, open: () => setEnabled(true) }}function FinishedHighlight() { // A fixed, hand-picked match rather than a resolver: nothing about a // completed match changes, so there's no "current" one to resolve. const [selectedId, setSelectedId] = useState<string | null>(null) const matchId = selectedId ?? FINISHED_MATCH_ID const match = useMatch({ matchId }) const { matches: competitionMatches, open } = useCompetitionMatches( match?.competition.id, match?.startTime.slice(0, 10) ) if (match === null) { return <p className="text-sm text-fd-muted-foreground">Couldn't load this match right now.</p> } return ( <div className="w-full max-w-sm"> {match === undefined ? ( <MatchHighlight loading /> ) : ( <MatchHighlight {...matchToMatchHighlightProps(match)} competitionMatches={competitionMatches == null ? competitionMatches : matchesToCompetitionMatches(competitionMatches, match.id)} onOpenCompetitionMatches={open} onSelectCompetitionMatch={setSelectedId} /> )} </div> )}function UpcomingHighlight() { const resolvedId = useSharedUpcomingMatchId() const [selectedId, setSelectedId] = useState<string | null>(null) const matchId = selectedId ?? resolvedId // One-shot, not live: nothing about an upcoming match changes before kickoff. const match = useMatch({ matchId: matchId ?? undefined }) const { matches: competitionMatches, open } = useCompetitionMatches( match?.competition.id, match?.startTime.slice(0, 10) ) if (matchId === null || match === null) { return <p className="text-sm text-fd-muted-foreground">No upcoming match to show right now.</p> } return ( <div className="w-full max-w-sm"> {match === undefined ? ( <MatchHighlight loading /> ) : ( <MatchHighlight {...matchToMatchHighlightProps(match)} competitionMatches={competitionMatches == null ? competitionMatches : matchesToCompetitionMatches(competitionMatches, match.id)} onOpenCompetitionMatches={open} onSelectCompetitionMatch={setSelectedId} /> )} </div> )}function LiveHighlight() { // Prefers a genuinely live match; falls back to the shared upcoming // match if nothing's live right now (e.g. quiet hours) rather than // showing a blank state. const resolvedId = useSharedLiveOrUpcomingMatchId() const [selectedId, setSelectedId] = useState<string | null>(null) const matchId = selectedId ?? resolvedId const match = useLiveMatch({ matchId: matchId ?? undefined }) const { matches: competitionMatches, open } = useCompetitionMatches( match?.competition.id, match?.startTime.slice(0, 10) ) if (matchId === null || match === null) { return <p className="text-sm text-fd-muted-foreground">No match to show right now.</p> } return ( <div className="w-full max-w-sm"> {match === undefined ? ( <MatchHighlight loading /> ) : ( <MatchHighlight {...matchToMatchHighlightProps(match)} competitionMatches={competitionMatches == null ? competitionMatches : matchesToCompetitionMatches(competitionMatches, match.id)} onOpenCompetitionMatches={open} onSelectCompetitionMatch={setSelectedId} /> )} </div> )}export function MatchHighlightDemo() { return ( <div className="flex w-full flex-col items-center gap-6"> <FinishedHighlight /> <UpcomingHighlight /> <LiveHighlight /> </div> )}A bigger, page-header version of Match Card: teams, score, and status, plus a genuinely ticking live clock and cards/corners below each team (soccer-specific, shown here for demo purposes; omit them for other sports). Meant for the top of a match detail page, not a list row.
The live clock
Pass elapsedSeconds and referenceTime together on clock and Match
Highlight ticks a real seconds timer client-side between server updates,
turning red while it's actually running:
clock={{
state: "live",
minute: 45,
stoppage: 3,
elapsedSeconds: 2832,
referenceTime: "2026-08-05T20:14:00.000Z",
}}renders as "47:12 +3": the clock itself keeps ticking uncapped past
45/90 through stoppage; stoppage (the ref's announced allotment) is
static, appended as-is rather than counted up. Ticking requires both the
outer status and clock.state to be "live"; a paused or half-time
match shows displayLong/display as a static label instead, e.g.
"Half-time".
The competition dropdown
Pass competitionMatches together with onOpenCompetitionMatches and the
competition name becomes a button. Clicking it calls
onOpenCompetitionMatches (fetch lazily there, only once it's actually
opened) and opens a dropdown listing competitionMatches, each row
showing crests and names; see
MatchHighlightCompetitionMatch below.
onSelectCompetitionMatch fires with the clicked match's id; what to do
with it (navigate, swap the highlighted match, both) is up to you (the
example above swaps the card). Omit either prop and the competition name
renders as plain, non-interactive text with no chevron.
competitionMatches being undefined renders a skeleton matching the
row's real dimensions.
const [matches, setMatches] = useState<Match[] | null | undefined>(undefined)
<MatchHighlight
{...highlightProps}
competitionMatches={matches}
onOpenCompetitionMatches={() => {
mrdoge.matches.list({ competitionIds: [id], date, status: [...] })
.then((r) => setMatches(r.data))
}}
onSelectCompetitionMatch={(matchId) => router.push(`/match/${matchId}`)}
/>Installation
pnpm dlx shadcn@latest add https://mrdoge.co/r/match-highlight.jsonUsage
import { MatchHighlight } from "@/components/match-highlight"
<MatchHighlight
status="live"
competition="Premier League"
region={{ name: "England", logoUrl: "..." }}
home={{ name: "Arsenal", logoUrl: "...", yellowCards: 1, corners: 4 }}
away={{ name: "Chelsea", logoUrl: "...", yellowCards: 2, corners: 6 }}
homeScore={2}
awayScore={1}
clock={{ state: "live", minute: 67, elapsedSeconds: 4020, referenceTime: "2026-08-05T20:14:00.000Z" }}
/>Use with the Mr. Doge SDK
Match Highlight takes plain props, so it works with any data source. See
the Match Highlight Adapter
for the real functions mapping a matches.get()/matches.subscribe()
response (and a matches.list() response, for the competition dropdown)
onto these props. These are the ones behind the example above.
Props
loading: true is mutually exclusive with every other prop; see
MatchHighlightSkeleton below. Everything below
applies when loading is omitted or false.
Prop
Type
MatchHighlightTeam
Prop
Type
MatchHighlightRegion
Prop
Type
MatchHighlightCompetitionMatch
Prop
Type
MatchHighlightClock
Prop
Type
MatchHighlightSkeleton
<MatchHighlight loading /> renders this internally, but it's also
exported on its own (import { MatchHighlightSkeleton } from "@/components/match-highlight") for a loading list of several highlights
before any of them have data yet. Takes className, same meaning as on
loading above.