"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
export interface BarRaceDatum {
/** Stable identity — bars are tracked by id as they overtake each other. */
id: string;
label: string;
value: number;
/** Bar colour; falls back to the accent / neutral scheme. */
color?: string;
}
export interface BarRaceProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: BarRaceDatum[];
/** Accessible name; also the caption of the screen-reader table. */
label: string;
/** Number of bars shown (the table still lists every row). */
maxBars?: number;
/** Duration of the reorder, width and number tweens, in ms. */
duration?: number;
/** Default number formatting when `format` is not given. */
notation?: "standard" | "compact";
/** Custom value formatter. */
format?: (value: number) => string;
/** Leader in the accent colour, others neutral (bars without their own colour). */
highlightLeader?: boolean;
/** Show the rank number before each label. */
showRank?: boolean;
/** Row height. */
size?: "sm" | "md" | "lg";
accent?: string;
/** Announce leader changes through a polite live region. */
announce?: boolean;
}
// Label columns narrow inside small containers (container queries on the root).
const ROW: Record<NonNullable<BarRaceProps["size"]>, { row: string; text: string; cols: string }> = {
sm: {
row: "h-6",
text: "text-xs",
cols: "grid-cols-[auto_minmax(0,4.5rem)_minmax(0,1fr)] @sm:grid-cols-[auto_minmax(0,6rem)_minmax(0,1fr)]",
},
md: {
row: "h-8",
text: "text-sm",
cols: "grid-cols-[auto_minmax(0,5rem)_minmax(0,1fr)] @md:grid-cols-[auto_minmax(0,7.5rem)_minmax(0,1fr)]",
},
lg: {
row: "h-11",
text: "text-base",
cols: "grid-cols-[auto_minmax(0,5.5rem)_minmax(0,1fr)] @md:grid-cols-[auto_minmax(0,8.5rem)_minmax(0,1fr)]",
},
};
const EASE = [0.22, 1, 0.36, 1] as const;
/** The value next to a bar: tweens from the number on screen to the new one (never from zero). */
function RaceValue({ value, format, duration }: { value: number; format: (v: number) => string; duration: number }) {
const nodeRef = React.useRef<HTMLSpanElement>(null);
const shownRef = React.useRef(value);
const [initialValue] = React.useState(value);
React.useEffect(() => {
const node = nodeRef.current;
if (!node) return;
const from = shownRef.current;
if (duration <= 0 || from === value) {
shownRef.current = value;
node.textContent = format(value);
return;
}
const controls = animate(from, value, {
duration: duration / 1000,
ease: EASE,
onUpdate: (v) => {
shownRef.current = v;
node.textContent = format(v);
},
});
return () => controls.stop();
}, [value, duration, format]);
return (
<span ref={nodeRef} className="tabular-nums">
{format(initialValue)}
</span>
);
}
/**
* A horizontal bar chart where bars grow, shrink and overtake each other: rows reorder with a
* shared layout animation, widths tween, and value labels count from their previous value.
* A screen-reader table mirrors the data and leader changes are announced politely.
*/
export function BarRace({
data,
label,
maxBars = 8,
duration = 700,
notation = "standard",
format,
highlightLeader = true,
showRank = true,
size = "md",
accent = "#FF7448",
announce = true,
className = "",
style,
...props
}: BarRaceProps) {
const reduceMotion = useReducedMotion() ?? false;
const ms = reduceMotion ? 0 : duration;
const defaultFormat = React.useMemo(() => {
const nf = new Intl.NumberFormat("en-US", {
notation,
maximumFractionDigits: notation === "compact" ? 1 : 0,
});
return (v: number) => nf.format(v);
}, [notation]);
const fmt = format ?? defaultFormat;
const sorted = [...data].sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
const visible = sorted.slice(0, Math.max(1, Math.round(maxBars)));
const top = Math.max(...visible.map((d) => d.value), 1e-9);
const leader = sorted[0];
// Announce a new leader (not on first render) — tracked while rendering, no effect round-trip.
const [lead, setLead] = React.useState({ id: leader?.id, message: "" });
if (leader && lead.id !== leader.id) {
setLead({ id: leader.id, message: `${leader.label} takes the lead with ${fmt(leader.value)}.` });
}
const sizes = ROW[size] ?? ROW.md;
const transition = { duration: ms / 1000, ease: EASE };
return (
<div
role="group"
aria-label={label}
className={`@container w-full text-zinc-950 dark:text-zinc-50 ${className}`}
style={{ ["--br-accent" as string]: accent, ...style }}
{...props}
>
<ol aria-hidden="true" className="relative flex flex-col gap-1.5">
<AnimatePresence mode="popLayout" initial={false}>
{visible.map((item, rank) => {
const pct = Math.max(0, (item.value / top) * 100);
const own = item.color;
const leaderTone = highlightLeader ? rank === 0 : true;
return (
<motion.li
key={item.id}
layout="position"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ layout: transition, opacity: { duration: Math.min(0.3, ms / 1000) } }}
className={`grid items-center gap-2 @md:gap-3 ${sizes.cols} ${sizes.row} ${sizes.text}`}
>
<span
className={`w-5 font-mono text-[10px] tabular-nums text-zinc-400 dark:text-zinc-500 ${showRank ? "" : "hidden"}`}
>
{String(rank + 1).padStart(2, "0")}
</span>
<span className={`truncate tracking-[-0.01em] ${showRank ? "" : "col-span-2"}`}>{item.label}</span>
<span className="relative h-[72%] pr-12 @md:pr-16">
<motion.span
className={`relative block h-full min-w-[2px] rounded-[3px] transition-colors duration-500 ${
own ? "" : leaderTone ? "bg-[var(--br-accent)]" : "bg-zinc-300 dark:bg-zinc-700"
}`}
style={own ? { backgroundColor: own } : undefined}
initial={false}
animate={{ width: `${pct}%` }}
transition={transition}
>
<span className="absolute top-1/2 left-full -translate-y-1/2 pl-2 font-mono text-[11px] whitespace-nowrap text-zinc-600 dark:text-zinc-300">
<RaceValue value={item.value} format={fmt} duration={ms} />
</span>
</motion.span>
</span>
</motion.li>
);
})}
</AnimatePresence>
</ol>
<table className="sr-only">
<caption>{label}</caption>
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Name</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
{sorted.map((item, rank) => (
<tr key={item.id}>
<td>{rank + 1}</td>
<th scope="row">{item.label}</th>
<td>{fmt(item.value)}</td>
</tr>
))}
</tbody>
</table>
{announce ? (
<p className="sr-only" aria-live="polite" aria-atomic="true">
{lead.message}
</p>
) : null}
</div>
);
}