Mr. Doge
Components

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.json

Usage

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.

On this page