A tabular odds board that flashes green or red with a directional arrow when a price drifts, retargeting on rapid updates.
npx shadcn@latest add @paragon/odds-movement"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>
);
}