Charts

Line Chart

A hand-built SVG line chart with path draw-in, snapped crosshair tooltip, multi-series legend, and ResizeObserver responsiveness.

Install

npx shadcn@latest add @paragon/line-chart

line-chart.tsx

"use client";

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

/** Fallback series palette when a series has no explicit color. */
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)",
];

/** Compute pleasant axis ticks spanning [0, ceil(max)] on a 1/2/5 scale. */
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;
}

/**
 * Monotone cubic ("smooth") path through points, or a straight polyline.
 * Tangents are clamped so the curve never overshoots between samples —
 * every control point is computed, never eyeballed.
 */
function buildPath(
  pts: { x: number; y: number }[],
  smooth: boolean,
): string {
  if (pts.length === 0) return "";
  if (pts.length === 1) return `M${pts[0].x} ${pts[0].y}`;
  if (!smooth)
    return pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x} ${p.y}`).join("");

  // Monotone cubic interpolation (Fritsch–Carlson) on the y values.
  const n = pts.length;
  const dx: number[] = [];
  const dy: number[] = [];
  const slope: number[] = [];
  for (let i = 0; i < n - 1; i++) {
    dx[i] = pts[i + 1].x - pts[i].x;
    dy[i] = pts[i + 1].y - pts[i].y;
    slope[i] = dx[i] === 0 ? 0 : dy[i] / dx[i];
  }
  const m: number[] = new Array(n);
  m[0] = slope[0];
  m[n - 1] = slope[n - 2];
  for (let i = 1; i < n - 1; i++) {
    if (slope[i - 1] * slope[i] <= 0) m[i] = 0;
    else m[i] = (slope[i - 1] + slope[i]) / 2;
  }
  for (let i = 0; i < n - 1; i++) {
    if (slope[i] === 0) {
      m[i] = 0;
      m[i + 1] = 0;
    } else {
      const a = m[i] / slope[i];
      const b = m[i + 1] / slope[i];
      const h = Math.hypot(a, b);
      if (h > 3) {
        const t = 3 / h;
        m[i] = t * a * slope[i];
        m[i + 1] = t * b * slope[i];
      }
    }
  }
  let d = `M${pts[0].x} ${pts[0].y}`;
  for (let i = 0; i < n - 1; i++) {
    const c1x = pts[i].x + dx[i] / 3;
    const c1y = pts[i].y + (m[i] * dx[i]) / 3;
    const c2x = pts[i + 1].x - dx[i] / 3;
    const c2y = pts[i + 1].y - (m[i + 1] * dx[i]) / 3;
    d += `C${c1x} ${c1y} ${c2x} ${c2y} ${pts[i + 1].x} ${pts[i + 1].y}`;
  }
  return d;
}

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

export interface LineChartProps extends React.ComponentProps<"div"> {
  series: LineChartSeries[];
  /** X-axis labels, one per data point. */
  labels: string[];
  height?: number;
  /** Stroke width of the series lines, in px. */
  strokeWidth?: number;
  /** Draw monotone-cubic smoothed lines instead of straight segments. */
  smooth?: boolean;
  /** Show horizontal gridlines behind the plot. */
  showGrid?: boolean;
  showLegend?: boolean;
  showYAxis?: boolean;
  formatValue?: (value: number) => string;
  /** Accessible description of the chart. */
  label?: string;
  /** Renders the final state immediately, no draw-in. */
  static?: boolean;
}

/**
 * A hand-built SVG line chart. Every coordinate is computed from a linear
 * scale — ticks, gridlines and points align exactly. Paths draw in once on
 * first view via stroke-dashoffset; hovering snaps a crosshair to the nearest
 * point and shows a cursor-anchored tooltip. Data points are keyboard
 * focusable (arrow/tab) and the legend both toggles (click) and isolates
 * (hover) series. Responsive through a ResizeObserver on the container —
 * hairlines and type never scale. Reduced motion renders the final state.
 */
export function LineChart({
  series,
  labels,
  height = 240,
  strokeWidth = 1.75,
  smooth = false,
  showGrid = true,
  showLegend,
  showYAxis = true,
  formatValue = (v) => v.toLocaleString("en-US"),
  label,
  static: isStatic = false,
  className,
  ...props
}: LineChartProps) {
  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 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;

  const dataMax = Math.max(
    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 legendVisible = showLegend ?? series.length > 1;

  return (
    <div
      ref={containerRef}
      data-slot="line-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 ?? `Line 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 }))}
        >
          {/* Gridlines + zero baseline */}
          {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"
              />
            ))}
          {/* Y labels */}
          {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>
            ))}
          {/* X labels */}
          {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,
          )}
          {/* Crosshair — instant, no transition */}
          {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
            />
          )}
          {/* Series paths */}
          {series.map((s, si) => {
            if (!visible[si]) return null;
            const pts = s.data.map((v, i) => ({ x: xFor(i), y: yFor(v) }));
            const dimmed = hovered !== null && hovered !== si;
            return (
              <path
                key={s.name}
                d={buildPath(pts, smooth)}
                fill="none"
                stroke={colorFor(si)}
                strokeWidth={strokeWidth}
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
                pathLength={1}
                strokeDasharray={1}
                style={{
                  strokeDashoffset: drawn ? 0 : 1,
                  opacity: dimmed ? 0.2 : 1,
                  transition: `stroke-dashoffset calc(${
                    animate ? 700 : 0
                  }ms * var(--duration-scale, 1)) var(--ease-out) calc(${
                    si * 120
                  }ms * var(--duration-scale, 1)), opacity 150ms var(--ease-out)`,
                }}
              />
            );
          })}
          {/* Snapped points at the crosshair */}
          {tip.visible &&
            series.map((s, si) =>
              visible[si] ? (
                <circle
                  key={s.name}
                  cx={xFor(tip.index)}
                  cy={yFor(s.data[tip.index] ?? 0)}
                  r={3.5}
                  fill="var(--color-background)"
                  stroke={colorFor(si)}
                  strokeWidth={1.75}
                  style={{ opacity: hovered !== null && hovered !== si ? 0.2 : 1 }}
                  aria-hidden
                />
              ) : null,
            )}
          {/* Keyboard-focusable hit targets, one per x index */}
          {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 — moves instantly with the cursor, fades 100ms 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,
            )}
          </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>
  );
}