A vertical match timeline of goals, cards, and subs with kind-colored icons; the newest event is highlighted and animates in.
npx shadcn@latest add @paragon/match-event-feed"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ArrowLeftRight,
CircleDot,
Square,
Flag,
Goal,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type MatchEventKind = "goal" | "yellow" | "red" | "sub" | "whistle";
export interface MatchEvent {
id: string;
kind: MatchEventKind;
/** Match minute, e.g. 67. */
minute: number;
title: string;
detail?: string;
/** Which side the event belongs to, for the rail alignment. */
side?: "home" | "away";
}
export interface MatchEventFeedProps extends React.ComponentProps<"ol"> {
/** Newest event first. */
events: MatchEvent[];
/** Renders entries in place, no enter animation. */
static?: boolean;
}
const KIND_META: Record<
MatchEventKind,
{ icon: React.ElementType; className: string; label: string }
> = {
goal: { icon: Goal, className: "bg-success/10 text-success", label: "Goal" },
yellow: {
icon: Square,
className: "bg-warning/15 text-warning",
label: "Yellow card",
},
red: {
icon: Square,
className: "bg-destructive/10 text-destructive",
label: "Red card",
},
sub: {
icon: ArrowLeftRight,
className: "bg-muted text-muted-foreground",
label: "Substitution",
},
whistle: {
icon: Flag,
className: "bg-muted text-muted-foreground",
label: "Whistle",
},
};
/**
* A vertical match timeline. Each event sits on a connecting rail with a
* kind-colored icon token; the newest event (first in the list) gets a tinted
* surface and animates in from the top with the house enter language. New
* entries are detected by id so replays and live pushes both animate, and the
* rail is a decorative layer. Reduced motion drops the enter movement.
*/
export function MatchEventFeed({
events,
static: isStatic = false,
className,
...props
}: MatchEventFeedProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const newestId = events[0]?.id;
return (
<ol
data-slot="match-event-feed"
className={cn(
"relative flex w-full max-w-sm list-none flex-col rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<AnimatePresence initial={false}>
{events.map((event, index) => {
const meta = KIND_META[event.kind];
const Icon = event.kind === "goal" ? CircleDot : meta.icon;
const isNewest = event.id === newestId;
const isLast = index === events.length - 1;
return (
<motion.li
key={event.id}
layout={animate ? "position" : false}
initial={
animate
? { opacity: 0, y: -12, filter: "blur(4px)" }
: false
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
className="relative flex gap-3 pb-4 last:pb-0"
>
{!isLast && (
<span
aria-hidden
className="absolute top-8 bottom-0 left-[15px] w-px bg-border"
/>
)}
<span
aria-hidden
className={cn(
"relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full",
meta.className,
)}
>
<Icon className="size-4" />
</span>
<div
className={cn(
"min-w-0 flex-1 rounded-lg px-3 py-1.5 transition-colors duration-200",
isNewest && "bg-accent",
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium">
{event.title}
</span>
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{event.minute}'
</span>
</div>
{event.detail && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
<span className="sr-only">{meta.label}: </span>
{event.detail}
</p>
)}
</div>
</motion.li>
);
})}
</AnimatePresence>
</ol>
);
}