A live scoreboard whose scores roll vertically on change, with a pulsing live pill and tabular period and clock.
npx shadcn@latest add @paragon/live-score"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface LiveScoreTeam {
/** Short team code, e.g. "ARS". */
code: string;
name: string;
score: number;
/** Optional accent color for the team crest dot. */
color?: string;
}
export interface LiveScoreProps extends React.ComponentProps<"div"> {
home: LiveScoreTeam;
away: LiveScoreTeam;
/** Period label, e.g. "2nd Half", "Q4", "OT". */
period?: string;
/** Game clock, monospace, e.g. "67:24". */
clock?: string;
/** Live status pill; false renders "Final". */
live?: boolean;
/** Renders score changes instantly, no roll. */
static?: boolean;
}
/**
* A scoreboard whose scores roll vertically when they change (direction-aware,
* always upward for a goal), with the period and clock in tabular figures.
* The live pill pulses on a CSS keyframe that freezes under reduced motion.
* Score rolls fall back to an instant swap when reduced motion is requested.
*/
export function LiveScore({
home,
away,
period = "2nd Half",
clock = "67:24",
live = true,
static: isStatic = false,
className,
...props
}: LiveScoreProps) {
return (
<div
data-slot="live-score"
className={cn(
"flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-muted-foreground">{period}</span>
{live ? <LivePill /> : (
<span className="font-medium text-muted-foreground">Final</span>
)}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<TeamCell team={home} align="start" />
<div className="flex items-center gap-2">
<ScoreRoll value={home.score} isStatic={isStatic} />
<span className="text-2xl font-light text-muted-foreground/50">
:
</span>
<ScoreRoll value={away.score} isStatic={isStatic} />
</div>
<TeamCell team={away} align="end" />
</div>
<div className="flex items-center justify-center">
<span className="rounded-md bg-muted px-2 py-1 font-mono text-xs tabular-nums text-muted-foreground">
{clock}
</span>
</div>
</div>
);
}
function TeamCell({
team,
align,
}: {
team: LiveScoreTeam;
align: "start" | "end";
}) {
return (
<div
className={cn(
"flex min-w-0 items-center gap-2",
align === "end" && "flex-row-reverse",
)}
>
<span
aria-hidden
className="size-6 shrink-0 rounded-full"
style={{ background: team.color ?? "var(--muted-foreground)" }}
/>
<div className={cn("min-w-0", align === "end" && "text-right")}>
<div className="truncate text-sm font-semibold">{team.code}</div>
<div className="truncate text-xs text-muted-foreground">
{team.name}
</div>
</div>
</div>
);
}
function ScoreRoll({ value, isStatic }: { value: number; isStatic: boolean }) {
const reducedMotion = useReducedMotion();
const immediate = isStatic || !!reducedMotion;
return (
<span className="relative inline-flex h-8 w-[0.7em] items-center justify-center overflow-hidden text-3xl font-semibold tabular-nums">
<span className="sr-only">{value}</span>
{immediate ? (
<span aria-hidden>{value}</span>
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={value}
aria-hidden
initial={{ y: "-100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
className="absolute inset-0 flex items-center justify-center"
>
{value}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
function LivePill() {
return (
<>
<style href="paragon-live-score" precedence="paragon">{`
@keyframes paragon-live-score-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@media (prefers-reduced-motion: reduce) {
[data-live-dot] { animation: none !important; }
}
`}</style>
<span className="inline-flex items-center gap-1.5 rounded-full bg-destructive/10 px-2 py-0.5 font-medium text-destructive">
<span
data-live-dot
aria-hidden
className="size-1.5 rounded-full bg-destructive"
style={{
animation: "paragon-live-score-pulse 1.6s var(--ease-in-out) infinite",
}}
/>
LIVE
</span>
</>
);
}