Mini Bar List
Charts

Mini Bar List

Ranked horizontal bars for top-pages and referrer panels — soft background fills scale in with a stagger and hover reveals exact values.

Install

npx shadcn@latest add @paragon/mini-bar-list

mini-bar-list.tsx

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