A hand-built SVG line chart with path draw-in, snapped crosshair tooltip, multi-series legend, and ResizeObserver responsiveness.
npx shadcn@latest add @paragon/line-chart"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>
);
}