Sports

Match Event Feed

A vertical match timeline of goals, cards, and subs with kind-colored icons; the newest event is highlighted and animates in.

Install

npx shadcn@latest add @paragon/match-event-feed

match-event-feed.tsx

"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}&apos;
                  </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>
  );
}