Fintech & Payments

Spend Category Bar

A horizontal stacked spend-by-category bar that wipes open once on view via clip-path, with per-segment hover tooltips and a legend.

Install

npx shadcn@latest add @paragon/spend-category-bar

spend-category-bar.tsx

"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>
  );
}