Sports

Odds Movement

A tabular odds board that flashes green or red with a directional arrow when a price drifts, retargeting on rapid updates.

Install

npx shadcn@latest add @paragon/odds-movement

odds-movement.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";

export interface OddsRow {
  id: string;
  label: string;
  /** Decimal odds, e.g. 2.35. */
  odds: number;
}

export interface OddsMovementProps extends React.ComponentProps<"table"> {
  rows: OddsRow[];
  /** Column heading over the market names. */
  marketLabel?: string;
  /** Renders value changes without the flash. */
  static?: boolean;
}

/**
 * A tabular odds board. When a row's odds change, the cell flashes green (drift
 * up) or red (drift in) via a re-triggerable CSS transition on a data
 * attribute — no keyframe, so rapid consecutive updates retarget cleanly — and
 * a directional arrow appears. The direction is derived from the previous
 * value held in a ref. Reduced motion (or `static`) shows the arrow without the
 * background flash.
 */
export function OddsMovement({
  rows,
  marketLabel = "Market",
  static: isStatic = false,
  className,
  ...props
}: OddsMovementProps) {
  return (
    <table
      data-slot="odds-movement"
      className={cn(
        "w-full max-w-sm border-separate border-spacing-0 overflow-hidden rounded-xl bg-card text-sm shadow-border",
        className,
      )}
      {...props}
    >
      <thead>
        <tr className="text-xs text-muted-foreground">
          <th className="px-4 py-2.5 text-left font-medium">{marketLabel}</th>
          <th className="px-4 py-2.5 text-right font-medium">Odds</th>
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <OddsCell key={row.id} row={row} isStatic={isStatic} />
        ))}
      </tbody>
    </table>
  );
}

function OddsCell({ row, isStatic }: { row: OddsRow; isStatic: boolean }) {
  const reducedMotion = useReducedMotion();
  const flash = !isStatic && !reducedMotion;

  const prev = React.useRef(row.odds);
  const [dir, setDir] = React.useState<"up" | "down" | null>(null);
  const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(() => {
    if (row.odds === prev.current) return;
    const next = row.odds > prev.current ? "up" : "down";
    prev.current = row.odds;
    setDir(next);
    if (timeout.current) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => setDir(null), 900);
  }, [row.odds]);

  React.useEffect(() => {
    return () => {
      if (timeout.current) clearTimeout(timeout.current);
    };
  }, []);

  return (
    <tr className="border-t border-border">
      <td className="border-t border-border px-4 py-3 font-medium">
        {row.label}
      </td>
      <td
        data-dir={dir ?? undefined}
        className={cn(
          "border-t border-border px-4 py-3 text-right",
          flash &&
            "transition-colors duration-700 ease-out data-[dir=up]:bg-success/15 data-[dir=down]:bg-destructive/15",
        )}
      >
        <span className="inline-flex items-center justify-end gap-1.5 tabular-nums">
          <span
            className={cn(
              "inline-flex w-3.5 justify-center transition-opacity duration-150",
              dir === "up" && "text-success",
              dir === "down" && "text-destructive",
              !dir && "opacity-0",
            )}
            aria-hidden
          >
            {dir === "up" && <ArrowUp className="size-3.5" />}
            {dir === "down" && <ArrowDown className="size-3.5" />}
          </span>
          <span className="w-10 font-semibold">{row.odds.toFixed(2)}</span>
        </span>
      </td>
    </tr>
  );
}