A horizontal stacked spend-by-category bar that wipes open once on view via clip-path, with per-segment hover tooltips and a legend.
npx shadcn@latest add @paragon/spend-category-bar"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface SpendCategory {
label: string;
/** Amount in major units. */
amount: number;
/** CSS color for the segment. Falls back to a palette by index. */
color?: string;
}
export interface SpendCategoryBarProps extends React.ComponentProps<"div"> {
categories: SpendCategory[];
currency?: string;
locale?: string;
/** Show the legend row under the bar. */
legend?: boolean;
}
// Restrained default palette (works in both themes) used when a segment omits
// its own color.
const PALETTE = [
"var(--color-foreground)",
"color-mix(in oklab, var(--color-foreground) 62%, var(--color-background))",
"color-mix(in oklab, var(--color-foreground) 42%, var(--color-background))",
"color-mix(in oklab, var(--color-foreground) 26%, var(--color-background))",
"color-mix(in oklab, var(--color-foreground) 14%, var(--color-background))",
];
/**
* A horizontal stacked bar of spend by category. On first scroll into view the
* whole bar wipes open once via a clip-path inset (left-to-right) — segment
* widths themselves stay fixed, so no layout thrash. Hovering a segment lifts
* a tooltip with its label, amount, and share; the segment brightens via a
* ring. The wipe runs once and never loops. Reduced motion shows the bar
* already open.
*/
export function SpendCategoryBar({
categories,
currency = "USD",
locale = "en-US",
legend = true,
className,
...props
}: SpendCategoryBarProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const reduced = useReducedMotion();
const [hovered, setHovered] = React.useState<number | null>(null);
const total = React.useMemo(
() => categories.reduce((s, c) => s + c.amount, 0),
[categories],
);
const fmt = React.useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency }),
[locale, currency],
);
const revealed = reduced || inView;
return (
<div ref={ref} className={cn("w-full max-w-lg", className)} {...props}>
{/* Bar */}
<div className="relative">
<div
className="flex h-3 w-full overflow-hidden rounded-full bg-muted transition-[clip-path] duration-500 ease-out motion-reduce:transition-none"
style={{
clipPath: revealed
? "inset(0 0 0 0 round 9999px)"
: "inset(0 100% 0 0 round 9999px)",
}}
>
{categories.map((c, i) => {
const share = total > 0 ? (c.amount / total) * 100 : 0;
return (
<button
type="button"
key={c.label}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
onFocus={() => setHovered(i)}
onBlur={() => setHovered(null)}
aria-label={`${c.label}: ${fmt.format(c.amount)}, ${share.toFixed(1)} percent`}
className={cn(
"relative h-full outline-none transition-[filter,opacity] duration-(--duration-fast) ease-out",
hovered !== null && hovered !== i && "opacity-55",
)}
style={{
flexBasis: `${share}%`,
backgroundColor: c.color ?? PALETTE[i % PALETTE.length],
}}
/>
);
})}
</div>
{/* Tooltip */}
{hovered !== null && (
<div
role="status"
className="pointer-events-none absolute -top-2 left-1/2 z-10 -translate-x-1/2 -translate-y-full rounded-lg bg-popover px-2.5 py-1.5 text-popover-foreground shadow-overlay"
>
<div className="flex items-center gap-2 whitespace-nowrap text-[12px]">
<span
aria-hidden
className="size-2 rounded-full"
style={{
backgroundColor:
categories[hovered].color ??
PALETTE[hovered % PALETTE.length],
}}
/>
<span className="font-medium">{categories[hovered].label}</span>
<span className="tabular-nums text-muted-foreground">
{fmt.format(categories[hovered].amount)} ·{" "}
{total > 0
? ((categories[hovered].amount / total) * 100).toFixed(1)
: "0.0"}
%
</span>
</div>
</div>
)}
</div>
{/* Legend */}
{legend && (
<ul className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{categories.map((c, i) => (
<li
key={c.label}
className={cn(
"flex items-center gap-1.5 text-[13px] transition-opacity duration-(--duration-fast)",
hovered !== null && hovered !== i && "opacity-55",
)}
>
<span
aria-hidden
className="size-2 shrink-0 rounded-full"
style={{
backgroundColor: c.color ?? PALETTE[i % PALETTE.length],
}}
/>
<span className="truncate text-muted-foreground">{c.label}</span>
<span className="ml-auto shrink-0 tabular-nums">
{fmt.format(c.amount)}
</span>
</li>
))}
</ul>
)}
</div>
);
}