Charts

Bar Chart

A vertical SVG bar chart with grouped and stacked modes, staggered rise-in, instant hover highlight with tooltip, and optional value labels.

Install

npx shadcn@latest add @paragon/bar-chart

bar-chart.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const SERIES_COLORS = [
  "oklch(0.585 0.17 260)",
  "oklch(0.68 0.13 165)",
  "oklch(0.76 0.13 85)",
  "oklch(0.62 0.18 305)",
  "oklch(0.66 0.16 25)",
];

function niceTicks(max: number, count = 4): number[] {
  if (max <= 0) return [0, 1];
  const rough = max / count;
  const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
  const norm = rough / magnitude;
  const step = (norm > 5 ? 10 : norm > 2 ? 5 : norm > 1 ? 2 : 1) * magnitude;
  const top = Math.ceil(max / step) * step;
  const ticks: number[] = [];
  for (let v = 0; v <= top + step / 2; v += step) ticks.push(v);
  return ticks;
}

export interface BarChartSeries {
  name: string;
  data: number[];
  color?: string;
}

export interface BarChartProps extends React.ComponentProps<"div"> {
  series: BarChartSeries[];
  /** Category labels, one per column. */
  labels: string[];
  /** How multiple series share a column. */
  mode?: "grouped" | "stacked";
  height?: number;
  /**
   * Fraction of each band left empty as gap between columns, 0–0.9.
   * 0.38 leaves a bar area of 62% of the band (the default rhythm).
   */
  bandGap?: number;
  /** Show horizontal gridlines behind the plot. */
  showGrid?: boolean;
  /** Value labels above each bar (grouped) or column total (stacked). */
  showValues?: boolean;
  showLegend?: boolean;
  showYAxis?: boolean;
  formatValue?: (value: number) => string;
  /** Accessible description of the chart. */
  label?: string;
  /** Renders the final state immediately, no rise-in. */
  static?: boolean;
}

/**
 * A hand-built SVG vertical bar chart with grouped and stacked modes. Column
 * bands, bar widths and gaps are derived from a band scale, and every bar
 * shares the axis's zero baseline exactly. Columns scale up from the baseline
 * with a stagger on first view; hovering highlights the column and shows a
 * cursor-anchored tooltip. Columns are keyboard focusable and the legend
 * toggles (click) / isolates (hover) series. Reduced motion renders the final
 * state immediately.
 */
export function BarChart({
  series,
  labels,
  mode = "grouped",
  height = 240,
  bandGap = 0.38,
  showGrid = true,
  showValues = false,
  showLegend,
  showYAxis = true,
  formatValue = (v) => v.toLocaleString("en-US"),
  label,
  static: isStatic = false,
  className,
  ...props
}: BarChartProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);
  const reducedMotion = useReducedMotion();
  const inView = useInView(containerRef, {
    once: true,
    margin: "0px 0px -48px 0px",
  });
  const [tip, setTip] = React.useState({ index: 0, x: 0, y: 0, visible: false });
  const [hidden, setHidden] = React.useState<Set<number>>(new Set());
  const [hovered, setHovered] = React.useState<number | null>(null);

  React.useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const isStacked = mode === "stacked" && series.length > 1;
  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const n = labels.length;
  const hasData = series.length > 0 && n > 0;
  const visible = series.map((_, i) => !hidden.has(i));
  const visibleCount = Math.max(1, visible.filter(Boolean).length);

  const pad = {
    top: showValues ? 18 : 10,
    right: 12,
    bottom: 22,
    left: showYAxis ? 44 : 8,
  };
  const innerW = Math.max(width - pad.left - pad.right, 0);
  const plotBottom = height - pad.bottom;
  const innerH = plotBottom - pad.top;

  const columnTotals = labels.map((_, i) =>
    series.reduce((sum, s, si) => sum + (visible[si] ? s.data[i] ?? 0 : 0), 0),
  );
  const dataMax = Math.max(
    1,
    ...(isStacked
      ? columnTotals
      : series.filter((_, i) => visible[i]).flatMap((s) => s.data)),
  );
  const ticks = niceTicks(dataMax);
  const yMax = ticks[ticks.length - 1];

  const band = n > 0 ? innerW / n : 0;
  const barArea = band * (1 - Math.min(0.9, Math.max(0, bandGap)));
  const groupGap = Math.min(4, barArea * 0.06);
  const subWidth = isStacked
    ? barArea
    : (barArea - (visibleCount - 1) * groupGap) / visibleCount;

  const xBand = (i: number) => pad.left + i * band + (band - barArea) / 2;
  const yFor = (v: number) => plotBottom - (v / yMax) * innerH;
  const colorFor = (i: number) =>
    series[i]?.color ?? SERIES_COLORS[i % SERIES_COLORS.length];

  const setIndex = React.useCallback(
    (index: number) => {
      const i = Math.min(n - 1, Math.max(0, index));
      setTip({ index: i, x: pad.left + i * band + band / 2, y: pad.top, visible: true });
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [n, band, pad.left],
  );

  const handleMove = (event: React.PointerEvent<SVGSVGElement>) => {
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect || band <= 0) return;
    const px = event.clientX - rect.left;
    const py = event.clientY - rect.top;
    const index = Math.min(
      n - 1,
      Math.max(0, Math.floor((px - pad.left) / band)),
    );
    setTip({ index, x: px, y: py, visible: true });
  };

  const toggle = (i: number) =>
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(i)) next.delete(i);
      else if (next.size < series.length - 1) next.add(i);
      return next;
    });

  const flip = width > 0 && tip.x > width * 0.6;
  const legendVisible = showLegend ?? series.length > 1;

  return (
    <div
      ref={containerRef}
      data-slot="bar-chart"
      className={cn("relative w-full", className)}
      {...props}
    >
      {!hasData ? (
        <div
          style={{ height }}
          className="flex items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground"
        >
          No data
        </div>
      ) : width > 0 ? (
        <svg
          role="img"
          aria-label={
            label ?? `Bar chart of ${series.map((s) => s.name).join(", ")}`
          }
          width={width}
          height={height}
          viewBox={`0 0 ${width} ${height}`}
          preserveAspectRatio="xMidYMid meet"
          className="block touch-none overflow-visible"
          onPointerMove={handleMove}
          onPointerLeave={() => setTip((t) => ({ ...t, visible: false }))}
        >
          {showGrid &&
            ticks.map((tick) => (
              <line
                key={tick}
                x1={pad.left}
                x2={width - pad.right}
                y1={yFor(tick)}
                y2={yFor(tick)}
                stroke="currentColor"
                strokeOpacity={tick === 0 ? 0.16 : 0.07}
                vectorEffect="non-scaling-stroke"
                shapeRendering="crispEdges"
              />
            ))}
          {showYAxis &&
            ticks.map((tick) => (
              <text
                key={tick}
                x={pad.left - 8}
                y={yFor(tick)}
                textAnchor="end"
                dominantBaseline="central"
                fontSize={10}
                className="fill-muted-foreground tabular-nums"
              >
                {formatValue(tick)}
              </text>
            ))}
          {labels.map((text, i) => (
            <text
              key={i}
              x={pad.left + i * band + band / 2}
              y={height - 6}
              textAnchor="middle"
              fontSize={10}
              className="fill-muted-foreground"
            >
              {text}
            </text>
          ))}

          {/* Hovered column band — instant, no transition */}
          {tip.visible && (
            <rect
              x={pad.left + tip.index * band}
              y={pad.top}
              width={band}
              height={innerH}
              fill="currentColor"
              fillOpacity={0.05}
              aria-hidden
            />
          )}

          {/* Columns rise from the baseline with a stagger */}
          {labels.map((_, i) => {
            const hoveredCol = tip.visible && tip.index === i;
            let stackedY = plotBottom;
            let slot = 0;
            return (
              <g
                key={i}
                style={{
                  transform: drawn ? "scaleY(1)" : "scaleY(0)",
                  transformOrigin: `0px ${plotBottom}px`,
                  transition: animate
                    ? `transform calc(400ms * var(--duration-scale, 1)) var(--ease-out) calc(${
                        i * 20
                      }ms * var(--duration-scale, 1))`
                    : undefined,
                }}
              >
                {series.map((s, si) => {
                  if (!visible[si]) return null;
                  const v = s.data[i] ?? 0;
                  const barH = (v / yMax) * innerH;
                  let x: number;
                  let y: number;
                  if (isStacked) {
                    x = xBand(i);
                    stackedY -= barH;
                    y = stackedY;
                  } else {
                    x = xBand(i) + slot * (subWidth + groupGap);
                    slot += 1;
                    y = yFor(v);
                  }
                  const dimmed = hovered !== null && hovered !== si;
                  return (
                    <rect
                      key={s.name}
                      x={x}
                      y={y}
                      width={Math.max(subWidth, 1)}
                      height={Math.max(barH, 0)}
                      rx={isStacked ? 0 : Math.min(3, subWidth / 2)}
                      fill={colorFor(si)}
                      fillOpacity={hoveredCol ? 1 : dimmed ? 0.25 : 0.85}
                      style={{ transition: "fill-opacity 150ms var(--ease-out)" }}
                    />
                  );
                })}
              </g>
            );
          })}

          {/* Value labels fade in after the rise */}
          {showValues &&
            labels.map((_, i) => {
              let slot = 0;
              const items = isStacked
                ? [{ x: xBand(i) + barArea / 2, v: columnTotals[i], top: columnTotals[i] }]
                : series
                    .map((s, si) => {
                      if (!visible[si]) return null;
                      const item = {
                        x: xBand(i) + slot * (subWidth + groupGap) + subWidth / 2,
                        v: s.data[i] ?? 0,
                        top: s.data[i] ?? 0,
                      };
                      slot += 1;
                      return item;
                    })
                    .filter((x): x is { x: number; v: number; top: number } => x !== null);
              return items.map(({ x, v, top }, k) => (
                <text
                  key={`${i}-${k}`}
                  x={x}
                  y={yFor(top) - 5}
                  textAnchor="middle"
                  fontSize={9.5}
                  className="fill-muted-foreground tabular-nums"
                  style={{
                    opacity: drawn ? 1 : 0,
                    transition: animate
                      ? `opacity calc(250ms * var(--duration-scale, 1)) var(--ease-out) calc(${
                          300 + i * 20
                        }ms * var(--duration-scale, 1))`
                      : undefined,
                  }}
                >
                  {formatValue(v)}
                </text>
              ));
            })}

          {/* Keyboard-focusable column hit targets */}
          {band > 0 &&
            labels.map((lbl, i) => (
              <rect
                key={i}
                x={pad.left + i * band}
                y={pad.top}
                width={band}
                height={innerH}
                fill="transparent"
                tabIndex={0}
                role="button"
                aria-label={`${lbl}: ${series
                  .filter((_, si) => visible[si])
                  .map((s) => `${s.name} ${formatValue(s.data[i] ?? 0)}`)
                  .join(", ")}${
                  isStacked ? `, total ${formatValue(columnTotals[i])}` : ""
                }`}
                className="cursor-pointer outline-none [&:focus-visible]:stroke-ring"
                style={{ strokeWidth: 2 }}
                onFocus={() => setIndex(i)}
                onBlur={() => setTip((t) => ({ ...t, visible: false }))}
                onKeyDown={(e) => {
                  if (e.key === "ArrowRight" || e.key === "ArrowUp") {
                    e.preventDefault();
                    (
                      e.currentTarget.parentElement?.querySelectorAll(
                        "rect[role='button']",
                      )[Math.min(n - 1, i + 1)] as SVGRectElement | undefined
                    )?.focus();
                  } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
                    e.preventDefault();
                    (
                      e.currentTarget.parentElement?.querySelectorAll(
                        "rect[role='button']",
                      )[Math.max(0, i - 1)] as SVGRectElement | undefined
                    )?.focus();
                  }
                }}
              />
            ))}
        </svg>
      ) : (
        <div style={{ height }} />
      )}

      {/* Tooltip — instant movement, 100ms fade on appear */}
      {hasData && (
        <div
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-10 min-w-32 rounded-lg bg-popover px-2.5 py-2 text-xs shadow-overlay transition-opacity duration-100"
          style={{
            opacity: tip.visible ? 1 : 0,
            transform: `translate(${tip.x}px, ${tip.y}px) translate(${
              flip ? "calc(-100% - 14px)" : "14px"
            }, 10px)`,
          }}
        >
          <div className="text-muted-foreground">{labels[tip.index]}</div>
          <div className="mt-1.5 flex flex-col gap-1">
            {series.map((s, si) =>
              visible[si] ? (
                <div key={s.name} className="flex items-center gap-1.5">
                  <span
                    className="size-2 shrink-0 rounded-full"
                    style={{ background: colorFor(si) }}
                  />
                  <span className="text-muted-foreground">{s.name}</span>
                  <span className="ml-auto pl-3 font-medium tabular-nums">
                    {formatValue(s.data[tip.index] ?? 0)}
                  </span>
                </div>
              ) : null,
            )}
            {isStacked && visible.filter(Boolean).length > 1 && (
              <div className="mt-0.5 flex items-center gap-1.5 border-t pt-1">
                <span className="text-muted-foreground">Total</span>
                <span className="ml-auto pl-3 font-medium tabular-nums">
                  {formatValue(columnTotals[tip.index] ?? 0)}
                </span>
              </div>
            )}
          </div>
        </div>
      )}

      {hasData && legendVisible && (
        <div className="mt-3 flex flex-wrap items-center gap-x-1 gap-y-1">
          {series.map((s, si) => (
            <button
              key={s.name}
              type="button"
              onClick={() => toggle(si)}
              onPointerEnter={() => setHovered(si)}
              onPointerLeave={() => setHovered(null)}
              aria-pressed={visible[si]}
              className="pressable inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs text-muted-foreground outline-none transition-[color,opacity] duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              style={{ opacity: visible[si] ? 1 : 0.4 }}
            >
              <span
                className="size-2 shrink-0 rounded-[3px] transition-transform duration-150 ease-out"
                style={{
                  background: colorFor(si),
                  transform: visible[si] ? "scale(1)" : "scale(0.6)",
                }}
              />
              <span className={cn(!visible[si] && "line-through")}>
                {s.name}
              </span>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}