Fazekit

Code

"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ChartBar, ChartColumn } from "lucide-react";
import { cn } from "@/lib/utils";

/* Theme-aware categorical palette (validated for light + dark and colour-vision deficiency). */
const PALETTE =
  "[--chart-1:#2a78d6] [--chart-2:#eb6834] [--chart-3:#1baf7a] [--chart-4:#eda100] [--chart-5:#e87ba4] [--chart-6:#008300] [--chart-7:#4a3aa7] [--chart-8:#e34948] " +
  "dark:[--chart-1:#3987e5] dark:[--chart-2:#d95926] dark:[--chart-3:#199e70] dark:[--chart-4:#c98500] dark:[--chart-5:#d55181] dark:[--chart-7:#9085e9] dark:[--chart-8:#e66767]";

export type BarSeries = { id: string; name: string; data: number[]; color?: string };
export type BarMode = "grouped" | "stacked";
export type BarOrientation = "vertical" | "horizontal";

export type BarChartProps = {
  labels: string[];
  series: BarSeries[];
  title?: string;
  description?: string;
  headline?: React.ReactNode;
  /** Controlled / initial mode. */
  mode?: BarMode;
  defaultMode?: BarMode;
  onModeChange?: (m: BarMode) => void;
  orientation?: BarOrientation;
  defaultOrientation?: BarOrientation;
  onOrientationChange?: (o: BarOrientation) => void;
  /** Show the grouped/stacked + orientation toggles in the header. */
  showControls?: boolean;
  showLegend?: boolean;
  height?: number;
  valueFormatter?: (v: number) => string;
  actions?: React.ReactNode;
  className?: string;
};

const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });

function useElementWidth<T extends HTMLElement>() {
  const ref = React.useRef<T>(null);
  const [width, setWidth] = React.useState(0);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return [ref, width] as const;
}

function niceTicks(max: number, count = 5) {
  const top = max <= 0 ? 1 : max;
  const raw = top / count;
  const exp = 10 ** Math.floor(Math.log10(raw));
  const f = raw / exp;
  const step = (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;
  const ticks: number[] = [];
  for (let v = 0; v <= Math.ceil(top / step) * step + step / 2; v += step) ticks.push(Number(v.toFixed(10)));
  return ticks;
}

/**
 * Bar path with rounded corners only at the data end. Both orientations emit the same
 * command sequence (M L Q L Q L Z) so Motion can morph between them.
 */
function barPath(x: number, y: number, w: number, h: number, r: number, horizontal: boolean) {
  const rr = Math.max(0, Math.min(r, horizontal ? h / 2 : w / 2, horizontal ? w : h));
  if (horizontal) {
    return `M${x},${y}L${x + w - rr},${y}Q${x + w},${y},${x + w},${y + rr}L${x + w},${y + h - rr}Q${x + w},${y + h},${x + w - rr},${y + h}L${x},${y + h}Z`;
  }
  return `M${x},${y + h}L${x},${y + rr}Q${x},${y},${x + rr},${y}L${x + w - rr},${y}Q${x + w},${y},${x + w},${y + rr}L${x + w},${y + h}Z`;
}

type Rect = { key: string; seriesId: string; cat: number; x: number; y: number; w: number; h: number; r: number; color: string; zero: string };

export function BarChart({
  labels,
  series,
  title,
  description,
  headline,
  mode: modeProp,
  defaultMode = "grouped",
  onModeChange,
  orientation: orientationProp,
  defaultOrientation = "vertical",
  onOrientationChange,
  showControls = true,
  showLegend = true,
  height = 300,
  valueFormatter = (v) => compact.format(v),
  actions,
  className,
}: BarChartProps) {
  const reduce = useReducedMotion();
  const [wrapRef, width] = useElementWidth<HTMLDivElement>();
  const [modeState, setModeState] = React.useState(defaultMode);
  const [orientState, setOrientState] = React.useState(defaultOrientation);
  const mode = modeProp ?? modeState;
  const orientation = orientationProp ?? orientState;
  const setMode = (m: BarMode) => {
    setModeState(m);
    onModeChange?.(m);
  };
  const setOrientation = (o: BarOrientation) => {
    setOrientState(o);
    onOrientationChange?.(o);
  };
  const [hidden, setHidden] = React.useState<Set<string>>(new Set());
  const [active, setActive] = React.useState<number | null>(null);
  const [intro, setIntro] = React.useState(true);
  React.useEffect(() => {
    const t = setTimeout(() => setIntro(false), 1400);
    return () => clearTimeout(t);
  }, []);

  const horizontal = orientation === "horizontal";
  const stacked = mode === "stacked";
  const colored = series.map((s, i) => ({ ...s, color: s.color ?? `var(--chart-${(i % 8) + 1})` }));
  const visible = colored.filter((s) => !hidden.has(s.id));
  const n = labels.length;

  const totals = labels.map((_, i) => visible.reduce((a, s) => a + Math.max(0, s.data[i] ?? 0), 0));
  const maxValue = stacked ? Math.max(0, ...totals) : Math.max(0, ...visible.flatMap((s) => s.data));
  const ticks = niceTicks(maxValue, horizontal ? (width < 480 ? 3 : 5) : 5);
  const vMax = ticks[ticks.length - 1];

  const longestLabel = Math.max(...labels.map((l) => l.length), 1);
  const longestTick = Math.max(...ticks.map((t) => valueFormatter(t).length));
  const m = horizontal
    ? { top: 6, right: 16, bottom: 26, left: Math.min(140, longestLabel * 6.8 + 14) }
    : { top: 10, right: 8, bottom: 28, left: longestTick * 6.6 + 14 };
  const innerW = Math.max(0, width - m.left - m.right);
  const innerH = Math.max(0, height - m.top - m.bottom);
  const bandSize = (horizontal ? innerH : innerW) / Math.max(n, 1);
  const bandPad = bandSize * (stacked ? 0.36 : 0.22);
  const inner = bandSize - bandPad;
  const scale = (v: number) => (v / (vMax || 1)) * (horizontal ? innerW : innerH);
  const GAP = 2;

  const rects: Rect[] = [];
  labels.forEach((_, ci) => {
    const bandStart = (horizontal ? m.top : m.left) + ci * bandSize + bandPad / 2;
    if (stacked) {
      const thick = Math.min(inner, 56);
      const offset = bandStart + (inner - thick) / 2;
      let acc = 0;
      const lastVisible = [...colored].reverse().find((s) => !hidden.has(s.id) && (s.data[ci] ?? 0) > 0)?.id;
      colored.forEach((s) => {
        const on = !hidden.has(s.id);
        const v = on ? Math.max(0, s.data[ci] ?? 0) : 0;
        const len = scale(v);
        const start = scale(acc);
        acc += v;
        const isTop = s.id === lastVisible;
        const gap = len > GAP * 2 && !isTop ? GAP : 0;
        const segLen = Math.max(0, len - gap);
        const r = isTop ? 4 : 0;
        if (horizontal) {
          rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: m.left + start, y: offset, w: segLen, h: thick, r, color: s.color, zero: barPath(m.left, offset, 0, thick, 0, true) });
        } else {
          const base = m.top + innerH;
          rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: offset, y: base - start - segLen, w: thick, h: segLen, r, color: s.color, zero: barPath(offset, base, thick, 0, 0, false) });
        }
      });
    } else {
      const k = Math.max(visible.length, 1);
      const thick = Math.min((inner - GAP * (k - 1)) / k, 40);
      const groupThick = thick * k + GAP * (k - 1);
      const offset = bandStart + (inner - groupThick) / 2;
      let slot = 0;
      colored.forEach((s) => {
        const on = !hidden.has(s.id);
        const pos = offset + (on ? slot : Math.max(0, slot - 1)) * (thick + GAP);
        if (on) slot++;
        const v = on ? Math.max(0, s.data[ci] ?? 0) : 0;
        const len = scale(v);
        if (horizontal) {
          rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: m.left, y: pos, w: len, h: on ? thick : 0, r: 4, color: s.color, zero: barPath(m.left, pos, 0, thick, 0, true) });
        } else {
          const base = m.top + innerH;
          rects.push({ key: `${s.id}-${ci}`, seriesId: s.id, cat: ci, x: pos, y: base - len, w: on ? thick : 0, h: len, r: 4, color: s.color, zero: barPath(pos, base, thick, 0, 0, false) });
        }
      });
    }
  });

  const toggle = (id: string) =>
    setHidden((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else if (colored.length - next.size > 1) next.add(id);
      return next;
    });

  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const p = horizontal ? e.clientY - rect.top - m.top : e.clientX - rect.left - m.left;
    const i = Math.floor(p / bandSize);
    setActive(i >= 0 && i < n ? i : null);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? -1;
    const fwd = horizontal ? "ArrowDown" : "ArrowRight";
    const back = horizontal ? "ArrowUp" : "ArrowLeft";
    let next: number | null = cur;
    if (e.key === fwd) next = Math.min(n - 1, cur + 1);
    else if (e.key === back) next = Math.max(0, cur < 0 ? n - 1 : cur - 1);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = n - 1;
    else if (e.key === "Escape") next = null;
    else return;
    e.preventDefault();
    setActive(next);
  };

  const rows = active === null ? [] : visible.map((s) => ({ ...s, value: s.data[active] ?? 0 }));
  const announce = active === null ? "" : `${labels[active]}: ${rows.map((r) => `${r.name} ${valueFormatter(r.value)}`).join(", ")}`;

  // tooltip anchor
  let tipStyle: React.CSSProperties = {};
  if (active !== null) {
    const center = (horizontal ? m.top : m.left) + active * bandSize + bandSize / 2;
    if (horizontal) {
      const below = center < height / 2;
      tipStyle = below ? { top: center + bandSize / 2, right: 8 } : { bottom: height - center + bandSize / 2, right: 8 };
    } else {
      tipStyle = center > width * 0.6 ? { right: width - center + bandSize / 2 - 4, top: 0 } : { left: center + bandSize / 2 - 4, top: 0 };
    }
  }

  const ease = [0.22, 1, 0.36, 1] as const;
  const labelEvery = horizontal ? 1 : Math.max(1, Math.ceil((n * (longestLabel * 6.5 + 10)) / Math.max(innerW, 1)));

  return (
    <figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", PALETTE, className)}>
      <div className="mb-4 flex flex-wrap items-start justify-between gap-3">
        <div className="min-w-0">
          {title && <figcaption className="text-sm font-medium text-muted-foreground">{title}</figcaption>}
          {headline && <div className="mt-1 text-2xl font-semibold tracking-tight">{headline}</div>}
          {description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
        </div>
        <div className="flex flex-wrap items-center gap-2">
          {actions}
          {showControls && (
            <>
              <div role="radiogroup" aria-label="Bar layout" className="flex rounded-md border p-0.5 text-xs">
                {(["grouped", "stacked"] as const).map((v) => (
                  <button
                    key={v}
                    type="button"
                    role="radio"
                    aria-checked={mode === v}
                    onClick={() => setMode(v)}
                    className={cn(
                      "rounded px-2 py-1 capitalize outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                      mode === v ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
                    )}
                  >
                    {v}
                  </button>
                ))}
              </div>
              <div role="radiogroup" aria-label="Orientation" className="flex rounded-md border p-0.5">
                {(
                  [
                    ["vertical", ChartColumn],
                    ["horizontal", ChartBar],
                  ] as const
                ).map(([v, Icon]) => (
                  <button
                    key={v}
                    type="button"
                    role="radio"
                    aria-checked={orientation === v}
                    aria-label={v === "vertical" ? "Vertical bars" : "Horizontal bars"}
                    title={v === "vertical" ? "Vertical" : "Horizontal"}
                    onClick={() => setOrientation(v)}
                    className={cn(
                      "grid size-6 place-items-center rounded outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                      orientation === v ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground",
                    )}
                  >
                    <Icon className="size-3.5" aria-hidden />
                  </button>
                ))}
              </div>
            </>
          )}
        </div>
      </div>

      {showLegend && colored.length > 1 && (
        <div role="group" aria-label="Toggle series" className="-ml-2 mb-2 flex flex-wrap items-center gap-1">
          {colored.map((s) => {
            const on = !hidden.has(s.id);
            return (
              <button
                key={s.id}
                type="button"
                aria-pressed={on}
                onClick={() => toggle(s.id)}
                className={cn(
                  "inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
                  on ? "text-foreground" : "text-muted-foreground/70 line-through",
                )}
              >
                <span aria-hidden className={cn("size-2 rounded-[3px]", !on && "opacity-30")} style={{ background: s.color }} />
                {s.name}
              </button>
            );
          })}
        </div>
      )}

      <div
        ref={wrapRef}
        tabIndex={0}
        role="group"
        aria-label={`${title ?? "Bar chart"}. Use arrow keys to inspect categories.`}
        onKeyDown={onKeyDown}
        onBlur={() => setActive(null)}
        className="relative w-full rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-card"
        style={{ height }}
      >
        {width > 0 && (
          <svg width={width} height={height} className="block select-none" aria-hidden onPointerMove={onPointerMove} onPointerLeave={() => setActive(null)}>
            {/* hover band */}
            {active !== null && (
              <rect
                x={horizontal ? m.left : m.left + active * bandSize}
                y={horizontal ? m.top + active * bandSize : m.top}
                width={horizontal ? innerW : bandSize}
                height={horizontal ? bandSize : innerH}
                rx={6}
                className="fill-muted/70"
              />
            )}
            {/* value axis grid */}
            {ticks.map((t) => {
              const p = scale(t);
              return horizontal ? (
                <g key={t}>
                  <line x1={m.left + p} x2={m.left + p} y1={m.top} y2={m.top + innerH} className={cn("stroke-border", t !== 0 && "[stroke-dasharray:2_4]")} />
                  <text x={m.left + p} y={height - 8} textAnchor={t === 0 ? "start" : "middle"} className="fill-muted-foreground text-[11px] tabular-nums">
                    {valueFormatter(t)}
                  </text>
                </g>
              ) : (
                <g key={t}>
                  <line x1={m.left} x2={m.left + innerW} y1={m.top + innerH - p} y2={m.top + innerH - p} className={cn("stroke-border", t !== 0 && "[stroke-dasharray:2_4]")} />
                  <text x={m.left - 10} y={m.top + innerH - p} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-[11px] tabular-nums">
                    {valueFormatter(t)}
                  </text>
                </g>
              );
            })}
            {/* category labels */}
            {labels.map((l, i) => {
              if (i % labelEvery !== 0) return null;
              const c = (horizontal ? m.top : m.left) + i * bandSize + bandSize / 2;
              return horizontal ? (
                <text key={`${l}-${i}`} x={m.left - 10} y={c} dy="0.32em" textAnchor="end" className={cn("fill-muted-foreground text-[11px]", active === i && "fill-foreground")}>
                  {l}
                </text>
              ) : (
                <text key={`${l}-${i}`} x={c} y={height - 8} textAnchor="middle" className={cn("fill-muted-foreground text-[11px]", active === i && "fill-foreground")}>
                  {l}
                </text>
              );
            })}
            {/* bars */}
            {rects.map((r, idx) => (
              <motion.path
                key={r.key}
                initial={reduce ? false : { d: r.zero }}
                animate={{ d: barPath(r.x, r.y, r.w, r.h, r.r, horizontal), opacity: active === null || active === r.cat ? 1 : 0.4 }}
                transition={{
                  d: { duration: reduce ? 0 : 0.6, ease, delay: intro && !reduce ? (idx % n) * 0.04 + Math.floor(idx / n) * 0.02 : 0 },
                  opacity: { duration: 0.15 },
                }}
                fill={r.color}
              />
            ))}
          </svg>
        )}
        {active !== null && (
          <div className="pointer-events-none absolute z-10 min-w-40 rounded-lg border bg-popover/95 px-3 py-2 text-xs text-popover-foreground shadow-lg backdrop-blur-sm" style={tipStyle}>
            <p className="mb-1.5 font-medium">{labels[active]}</p>
            <ul className="space-y-1">
              {rows.map((r) => (
                <li key={r.id} className="flex items-center gap-2">
                  <span aria-hidden className="size-2 rounded-[3px]" style={{ background: r.color }} />
                  <span className="text-muted-foreground">{r.name}</span>
                  <span className="ml-auto pl-4 font-medium tabular-nums">{valueFormatter(r.value)}</span>
                </li>
              ))}
              {stacked && rows.length > 1 && (
                <li className="mt-1 flex items-center gap-2 border-t pt-1.5">
                  <span className="text-muted-foreground">Total</span>
                  <span className="ml-auto pl-4 font-semibold tabular-nums">{valueFormatter(totals[active])}</span>
                </li>
              )}
            </ul>
          </div>
        )}
        <p aria-live="polite" className="sr-only">
          {announce}
        </p>
      </div>

      <table className="sr-only">
        <caption>{title ?? "Bar chart data"}</caption>
        <thead>
          <tr>
            <th scope="col">Category</th>
            {colored.map((s) => (
              <th key={s.id} scope="col">
                {s.name}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {labels.map((l, i) => (
            <tr key={`${l}-${i}`}>
              <th scope="row">{l}</th>
              {colored.map((s) => (
                <td key={s.id}>{valueFormatter(s.data[i] ?? 0)}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </figure>
  );
}

More in Data Display

View all →