Fazekit

Code

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

/* ------------------------------------------------------------------ */
/* Theme-aware categorical palette (validated for light + dark, CVD)   */
/* ------------------------------------------------------------------ */
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 AreaSeries = {
  id: string;
  name: string;
  data: number[];
  /** Any CSS colour. Defaults to the palette slot for the series index. */
  color?: string;
  /** Start hidden (can be toggled on in the legend). */
  hidden?: boolean;
};

export type AreaChartProps = {
  labels: string[];
  series: AreaSeries[];
  title?: string;
  description?: string;
  /** Big number shown under the title (e.g. a total). */
  headline?: React.ReactNode;
  curve?: "smooth" | "linear";
  /** Draw gradient fills under the lines. */
  area?: boolean;
  height?: number;
  showGrid?: boolean;
  showLegend?: boolean;
  /** Include zero in the y-domain (default true). */
  zeroBaseline?: boolean;
  valueFormatter?: (value: number) => string;
  /** Formats the tooltip header / announcement for an x label. */
  labelFormatter?: (label: string, index: number) => string;
  /** Shows a total row in the tooltip. */
  showTotal?: boolean;
  actions?: React.ReactNode;
  className?: string;
};

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

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 niceStep(raw: number) {
  if (raw <= 0 || !Number.isFinite(raw)) return 1;
  const exp = 10 ** Math.floor(Math.log10(raw));
  const f = raw / exp;
  return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;
}

function niceTicks(min: number, max: number, count = 5) {
  if (min === max) max = min + 1;
  const step = niceStep((max - min) / count);
  const lo = Math.floor(min / step) * step;
  const hi = Math.ceil(max / step) * step;
  const ticks: number[] = [];
  for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toFixed(10)));
  return ticks;
}

/** Monotone cubic interpolation — smooth, never overshoots the data. */
function smoothPath(pts: [number, number][]) {
  const n = pts.length;
  if (n < 2) return n ? `M${pts[0][0]},${pts[0][1]}` : "";
  const dx: number[] = [];
  const m: number[] = [];
  for (let i = 0; i < n - 1; i++) {
    dx[i] = pts[i + 1][0] - pts[i][0];
    m[i] = dx[i] ? (pts[i + 1][1] - pts[i][1]) / dx[i] : 0;
  }
  const t: number[] = [m[0]];
  for (let i = 1; i < n - 1; i++) t[i] = m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2;
  t[n - 1] = m[n - 2];
  for (let i = 0; i < n - 1; i++) {
    if (m[i] === 0) {
      t[i] = 0;
      t[i + 1] = 0;
      continue;
    }
    const a = t[i] / m[i];
    const b = t[i + 1] / m[i];
    const s = a * a + b * b;
    if (s > 9) {
      const k = 3 / Math.sqrt(s);
      t[i] = k * a * m[i];
      t[i + 1] = k * b * m[i];
    }
  }
  let d = `M${pts[0][0]},${pts[0][1]}`;
  for (let i = 0; i < n - 1; i++) {
    const h = dx[i] / 3;
    d += `C${pts[i][0] + h},${pts[i][1] + t[i] * h},${pts[i + 1][0] - h},${pts[i + 1][1] - t[i + 1] * h},${pts[i + 1][0]},${pts[i + 1][1]}`;
  }
  return d;
}

/** Linear path written with the same command count as the smooth one so both can morph. */
function linearPath(pts: [number, number][]) {
  if (pts.length < 2) return pts.length ? `M${pts[0][0]},${pts[0][1]}` : "";
  let d = `M${pts[0][0]},${pts[0][1]}`;
  for (let i = 1; i < pts.length; i++) {
    const [x0, y0] = pts[i - 1];
    const [x1, y1] = pts[i];
    d += `C${x0 + (x1 - x0) / 3},${y0 + (y1 - y0) / 3},${x0 + ((x1 - x0) * 2) / 3},${y0 + ((y1 - y0) * 2) / 3},${x1},${y1}`;
  }
  return d;
}

const MARGIN = { top: 12, right: 12, bottom: 28 };

export function AreaChart({
  labels,
  series,
  title,
  description,
  headline,
  curve = "smooth",
  area = true,
  height = 280,
  showGrid = true,
  showLegend = true,
  zeroBaseline = true,
  valueFormatter = defaultFormat,
  labelFormatter = (l) => l,
  showTotal = false,
  actions,
  className,
}: AreaChartProps) {
  const reduce = useReducedMotion();
  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [wrapRef, width] = useElementWidth<HTMLDivElement>();
  const [hidden, setHidden] = React.useState<Set<string>>(() => new Set(series.filter((s) => s.hidden).map((s) => s.id)));
  const [active, setActive] = React.useState<number | null>(null);
  const [focused, setFocused] = React.useState(false);
  const [drawn, setDrawn] = React.useState(false);

  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 values = visible.flatMap((s) => s.data);
  const dataMin = values.length ? Math.min(...values) : 0;
  const dataMax = values.length ? Math.max(...values) : 1;
  const ticks = niceTicks(zeroBaseline ? Math.min(0, dataMin) : dataMin, dataMax, height < 200 ? 3 : 5);
  const yMin = ticks[0];
  const yMax = ticks[ticks.length - 1];

  const longestTick = Math.max(...ticks.map((t) => valueFormatter(t).length));
  const left = Math.max(28, longestTick * 6.6 + 12);
  const innerW = Math.max(0, width - left - MARGIN.right);
  const innerH = Math.max(0, height - MARGIN.top - MARGIN.bottom);
  const n = labels.length;
  const x = (i: number) => left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
  const y = (v: number) => MARGIN.top + innerH - ((v - yMin) / (yMax - yMin || 1)) * innerH;
  const baseY = y(Math.max(yMin, Math.min(0, yMax)));

  const labelEvery = Math.max(1, Math.ceil((n * 56) / Math.max(innerW, 1)));

  const paths = colored.map((s) => {
    const pts = s.data.map((v, i) => [x(i), y(v)] as [number, number]);
    const line = curve === "smooth" ? smoothPath(pts) : linearPath(pts);
    const fill = pts.length ? `${line}L${pts[pts.length - 1][0]},${baseY}L${pts[0][0]},${baseY}Z` : "";
    return { ...s, line, fill };
  });

  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 indexFromPointer = (clientX: number, rect: DOMRect) => {
    const px = clientX - rect.left - left;
    return Math.max(0, Math.min(n - 1, Math.round((px / Math.max(innerW, 1)) * (n - 1))));
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (!n) return;
    const cur = active ?? n - 1;
    let next: number | null = cur;
    if (e.key === "ArrowRight") next = Math.min(n - 1, cur + 1);
    else if (e.key === "ArrowLeft") next = Math.max(0, 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 tooltipRows = active === null ? [] : visible.map((s) => ({ ...s, value: s.data[active] ?? 0 }));
  const total = tooltipRows.reduce((a, r) => a + r.value, 0);
  const announce =
    active === null ? "" : `${labelFormatter(labels[active], active)}: ${tooltipRows.map((r) => `${r.name} ${valueFormatter(r.value)}`).join(", ")}`;
  const flip = active !== null && x(active) > width * 0.6;
  const ready = width > 0;

  return (
    <figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", PALETTE, className)}>
      {(title || headline || showLegend || actions) && (
        <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">
            {showLegend && colored.length > 1 && (
              <div role="group" aria-label="Toggle series" className="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 transition-colors outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
                        on ? "text-foreground" : "text-muted-foreground/70 line-through decoration-muted-foreground/50",
                      )}
                    >
                      <span
                        aria-hidden
                        className={cn("h-2 w-2 rounded-full transition-opacity", !on && "opacity-30")}
                        style={{ background: s.color }}
                      />
                      {s.name}
                    </button>
                  );
                })}
              </div>
            )}
            {actions}
          </div>
        </div>
      )}

      <div
        ref={wrapRef}
        tabIndex={0}
        role="group"
        aria-label={`${title ?? "Chart"}. Use the left and right arrow keys to inspect values.`}
        onKeyDown={onKeyDown}
        onFocus={() => setFocused(true)}
        onBlur={() => {
          setFocused(false);
          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 }}
      >
        {ready && (
          <svg
            width={width}
            height={height}
            className="block touch-pan-y overflow-visible select-none"
            aria-hidden
            onPointerMove={(e) => setActive(indexFromPointer(e.clientX, e.currentTarget.getBoundingClientRect()))}
            onPointerLeave={() => {
              if (!focused) setActive(null);
            }}
          >
            <defs>
              {paths.map((s) => (
                <linearGradient key={s.id} id={`${uid}-g-${s.id}`} x1="0" x2="0" y1="0" y2="1">
                  <stop offset="0%" style={{ stopColor: s.color, stopOpacity: 0.28 }} />
                  <stop offset="95%" style={{ stopColor: s.color, stopOpacity: 0 }} />
                </linearGradient>
              ))}
              <clipPath id={`${uid}-clip`}>
                <motion.rect
                  x={0}
                  y={0}
                  height={height}
                  initial={{ width: reduce ? width : 0 }}
                  animate={{ width }}
                  transition={{ duration: reduce ? 0 : 1.1, ease: [0.22, 1, 0.36, 1] }}
                  onAnimationComplete={() => setDrawn(true)}
                />
              </clipPath>
            </defs>

            {/* grid + y axis */}
            {ticks.map((t) => (
              <g key={t}>
                {showGrid && (
                  <line
                    x1={left}
                    x2={left + innerW}
                    y1={y(t)}
                    y2={y(t)}
                    className={cn("stroke-border", t !== 0 && "[stroke-dasharray:2_4]")}
                    strokeWidth={1}
                  />
                )}
                <text x={left - 10} y={y(t)} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-[11px] tabular-nums">
                  {valueFormatter(t)}
                </text>
              </g>
            ))}

            {/* x labels */}
            {labels.map((l, i) =>
              i % labelEvery === 0 ? (
                <text
                  key={`${l}-${i}`}
                  x={x(i)}
                  y={height - 8}
                  textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
                  className={cn("fill-muted-foreground text-[11px] transition-colors", active === i && "fill-foreground")}
                >
                  {l}
                </text>
              ) : null,
            )}

            {/* series */}
            <g clipPath={drawn ? undefined : `url(#${uid}-clip)`}>
              {paths.map((s) => {
                const on = !hidden.has(s.id);
                return (
                  <g key={s.id}>
                    {area && (
                      <motion.path
                        initial={false}
                        animate={{ d: s.fill, opacity: on ? 1 : 0 }}
                        transition={{ duration: reduce ? 0 : 0.5, ease: [0.22, 1, 0.36, 1] }}
                        fill={`url(#${uid}-g-${s.id})`}
                      />
                    )}
                    <motion.path
                      initial={false}
                      animate={{ d: s.line, opacity: on ? 1 : 0 }}
                      transition={{ duration: reduce ? 0 : 0.5, ease: [0.22, 1, 0.36, 1] }}
                      fill="none"
                      stroke={s.color}
                      strokeWidth={2}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </g>
                );
              })}
            </g>

            {/* crosshair */}
            {active !== null && (
              <g pointerEvents="none">
                <line
                  x1={x(active)}
                  x2={x(active)}
                  y1={MARGIN.top}
                  y2={MARGIN.top + innerH}
                  className="stroke-muted-foreground/40"
                  strokeWidth={1}
                />
                {visible.map((s) => (
                  <circle
                    key={s.id}
                    cx={x(active)}
                    cy={y(s.data[active] ?? 0)}
                    r={4.5}
                    fill={s.color}
                    className="stroke-card"
                    strokeWidth={2}
                  />
                ))}
              </g>
            )}

            {/* hit area */}
            <rect x={left} y={0} width={innerW} height={height} fill="transparent" />
          </svg>
        )}

        {ready && active !== null && (
          <div
            className="pointer-events-none absolute top-1 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={flip ? { right: width - x(active) + 12 } : { left: x(active) + 12 }}
          >
            <p className="mb-1.5 font-medium">{labelFormatter(labels[active], active)}</p>
            <ul className="space-y-1">
              {tooltipRows.map((r) => (
                <li key={r.id} className="flex items-center gap-2">
                  <span aria-hidden className="h-2.5 w-1 rounded-full" 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>
              ))}
              {showTotal && tooltipRows.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(total)}</span>
                </li>
              )}
            </ul>
          </div>
        )}
        <p aria-live="polite" className="sr-only">
          {announce}
        </p>
      </div>

      {/* Screen-reader data table */}
      <table className="sr-only">
        <caption>{title ?? "Chart data"}</caption>
        <thead>
          <tr>
            <th scope="col">Label</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">{labelFormatter(l, i)}</th>
              {colored.map((s) => (
                <td key={s.id}>{valueFormatter(s.data[i] ?? 0)}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </figure>
  );
}

More in Data Display

View all →