Charts

Area Chart

A stacked or overlapping SVG area chart that rises from the baseline on first view, with a single crosshair tooltip for all series.

Install

npx shadcn@latest add @paragon/area-chart

area-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 AreaChartSeries {
  name: string;
  data: number[];
  color?: string;
}

export interface AreaChartProps extends React.ComponentProps<"div"> {
  series: AreaChartSeries[];
  /** X-axis labels, one per data point. */
  labels: string[];
  /** Stack series on top of each other. Defaults to true for multiple series. */
  stacked?: boolean;
  height?: number;
  /** Peak opacity of the area fill, 0–1. */
  fillOpacity?: number;
  /** Fade the fill toward the baseline with a vertical gradient. */
  gradient?: 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 area chart, stacked or overlapping. Every point is placed
 * from a linear scale so tops and gridlines align exactly. Areas rise from the
 * baseline via a scaleY transform on first view (top strokes keep constant
 * width via vector-effect); hovering snaps a crosshair and shows every series
 * value in one cursor-anchored tooltip. Data points are keyboard focusable and
 * the legend toggles (click) / isolates (hover) series. Reduced motion renders
 * the final state immediately.
 */
export function AreaChart({
  series,
  labels,
  stacked,
  height = 240,
  fillOpacity = 0.25,
  gradient = true,
  showLegend,
  showYAxis = true,
  formatValue = (v) => v.toLocaleString("en-US"),
  label,
  static: isStatic = false,
  className,
  ...props
}: AreaChartProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const gid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  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 = 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 pad = { top: 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;

  // Cumulative tops/bottoms for stacking; identity when overlapping.
  const bands = React.useMemo(() => {
    const running = new Array<number>(n).fill(0);
    return series.map((s, si) => {
      const on = !hidden.has(si);
      const bottom = isStacked ? [...running] : new Array<number>(n).fill(0);
      const top = s.data.map((v, i) => {
        const add = on ? v : 0;
        const t = (isStacked ? running[i] : 0) + add;
        if (isStacked) running[i] = t;
        return t;
      });
      return { top, bottom };
    });
  }, [series, isStacked, n, hidden]);

  const dataMax = Math.max(
    1,
    ...(isStacked
      ? (bands[bands.length - 1]?.top ?? [1])
      : series.filter((_, i) => visible[i]).flatMap((s) => s.data)),
  );
  const ticks = niceTicks(dataMax);
  const yMax = ticks[ticks.length - 1];

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

  const labelStep = Math.max(
    1,
    Math.ceil(n / Math.max(2, Math.floor(innerW / 64))),
  );

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

  const handleMove = (event: React.PointerEvent<SVGSVGElement>) => {
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect || innerW <= 0) return;
    const px = event.clientX - rect.left;
    const py = event.clientY - rect.top;
    const fraction = n > 1 ? (px - pad.left) / innerW : 0;
    const index = Math.min(n - 1, Math.max(0, Math.round(fraction * (n - 1))));
    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 anyVisible = visible.some(Boolean);
  const riseStyle = (delayMs: number): React.CSSProperties => ({
    transform: drawn ? "scaleY(1)" : "scaleY(0)",
    transformOrigin: `0px ${plotBottom}px`,
    transition: animate
      ? `transform calc(600ms * var(--duration-scale, 1)) var(--ease-out) calc(${delayMs}ms * var(--duration-scale, 1))`
      : undefined,
  });

  const legendVisible = showLegend ?? series.length > 1;

  return (
    <div
      ref={containerRef}
      data-slot="area-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 ??
            `${isStacked ? "Stacked area" : "Area"} 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 }))}
        >
          {gradient && (
            <defs>
              {series.map((s, si) => (
                <linearGradient
                  key={s.name}
                  id={`area-fill-${gid}-${si}`}
                  x1="0"
                  y1="0"
                  x2="0"
                  y2="1"
                >
                  <stop
                    offset="0%"
                    stopColor={colorFor(si)}
                    stopOpacity={fillOpacity}
                  />
                  <stop
                    offset="100%"
                    stopColor={colorFor(si)}
                    stopOpacity={fillOpacity * 0.15}
                  />
                </linearGradient>
              ))}
            </defs>
          )}
          {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) =>
            i % labelStep === 0 || i === n - 1 ? (
              <text
                key={i}
                x={xFor(i)}
                y={height - 6}
                textAnchor={i === n - 1 ? "end" : i === 0 ? "start" : "middle"}
                fontSize={10}
                className="fill-muted-foreground"
              >
                {text}
              </text>
            ) : null,
          )}

          {/* Stacked areas rise as one group; overlapping areas stagger. */}
          <g style={isStacked ? riseStyle(0) : undefined}>
            {series.map((s, si) => {
              if (!visible[si]) return null;
              const { top, bottom } = bands[si];
              const forward = top
                .map((v, i) => `${i === 0 ? "M" : "L"}${xFor(i)} ${yFor(v)}`)
                .join("");
              const backward = [...bottom]
                .map((v, i) => `L${xFor(i)} ${yFor(v)}`)
                .reverse()
                .join("");
              const dimmed = hovered !== null && hovered !== si;
              return (
                <g
                  key={s.name}
                  style={{
                    ...(isStacked ? undefined : riseStyle(si * 80)),
                    opacity: dimmed ? 0.25 : 1,
                    transition: "opacity 150ms var(--ease-out)",
                  }}
                >
                  <path
                    d={`${forward}${backward}Z`}
                    fill={
                      gradient
                        ? `url(#area-fill-${gid}-${si})`
                        : colorFor(si)
                    }
                    fillOpacity={gradient ? 1 : fillOpacity}
                  />
                  <path
                    d={forward}
                    fill="none"
                    stroke={colorFor(si)}
                    strokeWidth={1.75}
                    strokeLinejoin="round"
                    strokeLinecap="round"
                    vectorEffect="non-scaling-stroke"
                  />
                </g>
              );
            })}
          </g>

          {tip.visible && anyVisible && (
            <line
              x1={xFor(tip.index)}
              x2={xFor(tip.index)}
              y1={pad.top}
              y2={plotBottom}
              stroke="currentColor"
              strokeOpacity={0.22}
              vectorEffect="non-scaling-stroke"
              aria-hidden
            />
          )}
          {tip.visible &&
            series.map((s, si) =>
              visible[si] ? (
                <circle
                  key={s.name}
                  cx={xFor(tip.index)}
                  cy={yFor(bands[si].top[tip.index] ?? 0)}
                  r={3.5}
                  fill="var(--color-background)"
                  stroke={colorFor(si)}
                  strokeWidth={1.75}
                  style={{
                    opacity: hovered !== null && hovered !== si ? 0.25 : 1,
                  }}
                  aria-hidden
                />
              ) : null,
            )}
          {/* Keyboard-focusable hit targets */}
          {innerW > 0 &&
            labels.map((lbl, i) => {
              const stepW = n > 1 ? innerW / (n - 1) : innerW;
              return (
                <rect
                  key={i}
                  x={xFor(i) - stepW / 2}
                  y={pad.top}
                  width={stepW}
                  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(", ")}`}
                  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 && anyVisible ? 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(
                    series.reduce(
                      (sum, s, si) =>
                        sum + (visible[si] ? s.data[tip.index] ?? 0 : 0),
                      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-full 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>
  );
}