Ranked horizontal bars for top-pages and referrer panels — soft background fills scale in with a stagger and hover reveals exact values.
npx shadcn@latest add @paragon/mini-bar-list"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const compactFormat = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export interface MiniBarListItem {
label: string;
value: number;
}
export interface MiniBarListProps extends React.ComponentProps<"div"> {
/** Rows, already ranked — rendered in the order given. */
items: MiniBarListItem[];
/** Bar tint. */
color?: string;
/** Row height in px. */
barHeight?: number;
/** Show the value column. */
showValues?: boolean;
/** Compact value shown in the fixed value column. */
formatValue?: (value: number) => string;
/** Exact value revealed on row hover / focus in a tooltip. */
formatExactValue?: (value: number) => string;
/** Renders the final state immediately, no scale-in. */
static?: boolean;
}
/**
* Ranked horizontal bars for top-pages / referrers panels — the
* Vercel-analytics look. Widths come from a computed linear scale against the
* largest value and are painted with an animatable `clip-path` inset, so the
* corner radius stays crisp at every length, the bars sweep open with a 30ms
* stagger on first view, AND later value changes retarget mid-flight instead
* of jumping. The value column is a fixed tabular column that never overlaps
* the label or bar. Hovering, tapping, or keyboard-focusing a row (arrows
* traverse; Home/End jump) reveals the exact value and share of the list
* total in a tooltip. Reduced motion renders the final state immediately.
*/
export function MiniBarList({
items,
color = "oklch(0.585 0.17 260)",
barHeight = 32,
showValues = true,
formatValue = (v) => compactFormat.format(v),
formatExactValue = (v) => v.toLocaleString("en-US"),
static: isStatic = false,
className,
...props
}: MiniBarListProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -24px 0px",
});
const [active, setActive] = React.useState<number | null>(null);
// Once the staggered enter finishes, drop the per-row delays so value
// changes retarget immediately instead of queueing behind them.
const [entered, setEntered] = React.useState(false);
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
React.useEffect(() => {
if (!animate || !inView) return;
const total = 450 + items.length * 30;
const timeout = setTimeout(() => setEntered(true), total);
return () => clearTimeout(timeout);
}, [animate, inView, items.length]);
const max = Math.max(1, ...items.map((item) => item.value));
const total = items.reduce((sum, item) => sum + item.value, 0);
const shareFormat = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}),
[],
);
// Reserve a fixed value column wide enough for the widest formatted value,
// measured in ch so it stays tabular and never overlaps the bar.
const valueColCh = showValues
? Math.max(
3,
...items.map((item) => formatValue(item.value).length),
)
: 0;
if (items.length === 0) {
return (
<div
ref={containerRef}
data-slot="mini-bar-list"
className={cn(
"flex w-full items-center justify-center rounded-md bg-current/[0.03] py-8 text-sm text-muted-foreground",
className,
)}
{...props}
>
No data
</div>
);
}
return (
<div
ref={containerRef}
role="list"
data-slot="mini-bar-list"
className={cn("flex w-full flex-col gap-1", className)}
{...props}
>
{items.map((item, i) => {
// Bar length as a clip inset from the right; `round` keeps the radius.
const inset = (1 - item.value / max) * 100;
return (
<div
key={item.label}
role="listitem"
tabIndex={0}
onPointerEnter={() => setActive(i)}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setActive((cur) => (cur === i ? null : cur));
}}
onFocus={() => setActive(i)}
onBlur={() => setActive((cur) => (cur === i ? null : cur))}
onKeyDown={(e) => {
const target =
e.key === "ArrowDown"
? Math.min(items.length - 1, i + 1)
: e.key === "ArrowUp"
? Math.max(0, i - 1)
: e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: null;
if (target === null) return;
e.preventDefault();
(
e.currentTarget.parentElement?.querySelectorAll(
"[role='listitem']",
)[target] as HTMLElement | undefined
)?.focus();
}}
className={cn(
"group relative flex items-center gap-3 rounded-md px-2.5 text-sm outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
)}
style={{ height: barHeight }}
>
<span
aria-hidden
className="absolute inset-y-0 left-0 w-full rounded-md"
style={{
background: color,
opacity: active === i ? 0.2 : 0.13,
clipPath: drawn
? `inset(0 ${inset.toFixed(3)}% 0 0 round 6px)`
: "inset(0 100% 0 0 round 6px)",
transition: animate
? `clip-path 450ms var(--ease-out) ${
entered ? 0 : i * 30
}ms, opacity 150ms var(--ease-out)`
: "opacity 150ms var(--ease-out)",
}}
/>
<span className="relative min-w-0 flex-1 truncate">{item.label}</span>
{showValues && (
<span
className="relative shrink-0 text-right text-muted-foreground tabular-nums"
style={{ width: `${valueColCh}ch` }}
>
{formatValue(item.value)}
</span>
)}
{/* Per-row tooltip with the exact value and share of the total */}
{active === i && (
<span
role="status"
className="pointer-events-none absolute right-2.5 bottom-full z-10 mb-1 whitespace-nowrap rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground shadow-overlay tabular-nums"
>
{formatExactValue(item.value)}
{total > 0 && (
<span className="ml-1.5 opacity-70">
{shareFormat.format(item.value / total)} of total
</span>
)}
</span>
)}
</div>
);
})}
</div>
);
}