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; the value column is a fixed tabular column that never
 * overlaps the label or bar. Each row's soft background bar scales in from the
 * left with a 30ms stagger on first view; hovering or keyboard-focusing a row
 * reveals the exact value 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);

  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;
  const max = Math.max(1, ...items.map((item) => item.value));

  // 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) => (
        <div
          key={item.label}
          role="listitem"
          tabIndex={0}
          onPointerEnter={() => setActive(i)}
          onPointerLeave={() => setActive((cur) => (cur === i ? null : cur))}
          onFocus={() => setActive(i)}
          onBlur={() => setActive((cur) => (cur === i ? null : cur))}
          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 origin-left rounded-md transition-[opacity] duration-150 ease-out group-hover:opacity-100"
            style={{
              width: `${(item.value / max) * 100}%`,
              background: color,
              opacity: active === i ? 0.2 : 0.13,
              transform: drawn ? "scaleX(1)" : "scaleX(0)",
              transition: animate
                ? `transform 450ms var(--ease-out) ${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 */}
          {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)}
            </span>
          )}
        </div>
      ))}
    </div>
  );
}