Line chart
Multi-series lines with legend toggles, crosshair and shared tooltip.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/line-chart.jsoncomponents/examples/line-chart-demo.tsx
"use client";
import { LineChart } from "@/components/ui/line-chart";
const labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export default function LineChartDemo() {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card p-5">
<LineChart
labels={labels}
formatValue={(n) => `${Math.round(n)} ms`}
series={[
{ key: "p50", label: "p50", color: "var(--chart-1)", values: [42, 45, 41, 48, 52, 39, 37] },
{ key: "p95", label: "p95", color: "var(--chart-2)", values: [180, 196, 172, 240, 268, 150, 141] },
{ key: "slo", label: "SLO", color: "var(--muted-foreground)", values: [250, 250, 250, 250, 250, 250, 250], dashed: true },
]}
/>
</div>
);
}When to use
- Comparing several series over time.
When not to use
- Parts of a total: use a stacked area chart.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/line-chart.json1Install the dependencies
npm install motion2Copy the source into your project
components/ui/line-chart.tsx"use client"; import * as React from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface LineSeries { key: string; label: string; color: string; values: number[]; dashed?: boolean; } export interface LineChartProps { labels: string[]; series: LineSeries[]; height?: number; formatValue?: (n: number) => string; /** Show a clickable legend that toggles series. */ legend?: boolean; loading?: boolean; className?: string; } const PAD = { top: 10, right: 44, bottom: 26, left: 0 }; function useWidth<T extends HTMLElement>() { const ref = React.useRef<T>(null); const [w, setW] = React.useState(0); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver(([e]) => setW(e.contentRect.width)); ro.observe(el); return () => ro.disconnect(); }, []); return [ref, w] as const; } const compact = (n: number) => (Math.abs(n) >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : Math.abs(n) >= 1e3 ? `${+(n / 1e3).toFixed(1)}K` : String(Math.round(n))); /** Multi-series line chart with legend toggles, a crosshair and a shared tooltip. */ export function LineChart({ labels, series, height = 260, formatValue = (n) => n.toLocaleString("en-US"), legend = true, loading, className }: LineChartProps) { const [ref, width] = useWidth<HTMLDivElement>(); const [hover, setHover] = React.useState<number | null>(null); const [off, setOff] = React.useState<Set<string>>(new Set()); const shown = series.filter((s) => !off.has(s.key)); const all = shown.flatMap((s) => s.values); const max = Math.max(1, ...all) * 1.1; const min = Math.min(0, ...all); const n = labels.length; const innerW = Math.max(0, width - PAD.left - PAD.right); const innerH = height - PAD.top - PAD.bottom; const x = (i: number) => PAD.left + (n <= 1 ? 0 : (i / (n - 1)) * innerW); const y = (v: number) => PAD.top + ((max - v) / (max - min)) * innerH; const every = Math.max(1, Math.ceil(n / 6)); return ( <div className={cn("w-full", className)}> {legend && ( <div className="mb-3 flex flex-wrap gap-2"> {series.map((s) => { const isOff = off.has(s.key); return ( <button key={s.key} type="button" aria-pressed={!isOff} onClick={() => setOff((o) => { const nx = new Set(o); if (nx.has(s.key)) nx.delete(s.key); else if (shown.length > 1) nx.add(s.key); return nx; })} className={cn("flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs transition-opacity", isOff && "opacity-40")} > <span className="h-0.5 w-3 rounded" style={{ background: s.color }} /> {s.label} </button> ); })} </div> )} <div ref={ref} className="relative select-none" style={{ height }}> {loading && <div className="absolute inset-0 animate-pulse rounded-md bg-muted/60" />} {!loading && width > 0 && ( <svg width={width} height={height} className="block" role="img" aria-label={`Line chart: ${shown.map((s) => s.label).join(", ")}`} onPointerMove={(e) => { const r = e.currentTarget.getBoundingClientRect(); setHover(Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left - PAD.left) / Math.max(1, innerW)) * (n - 1))))); }} onPointerLeave={() => setHover(null)} > {[0, 0.25, 0.5, 0.75, 1].map((t) => { const v = min + (max - min) * (1 - t); return ( <g key={t}> <line x1={PAD.left} x2={PAD.left + innerW} y1={PAD.top + t * innerH} y2={PAD.top + t * innerH} stroke="var(--border)" strokeDasharray={t === 1 ? undefined : "2 4"} /> <text x={PAD.left + innerW + 8} y={PAD.top + t * innerH} dominantBaseline="middle" className="fill-muted-foreground text-[10px] tabular-nums">{compact(v)}</text> </g> ); })} {labels.map((l, i) => (i % every === 0 ? <text key={i} x={x(i)} y={height - 8} textAnchor={i === 0 ? "start" : "middle"} className="fill-muted-foreground text-[10px]">{l}</text> : null))} {shown.map((s) => { const d = s.values.map((v, i) => `${i ? "L" : "M"}${x(i)} ${y(v)}`).join(" "); return ( <motion.path key={s.key} initial={{ pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1, d }} transition={{ duration: 0.8, ease: [0.2, 0.8, 0.2, 1] }} fill="none" stroke={s.color} strokeWidth={2} strokeDasharray={s.dashed ? "5 5" : undefined} strokeLinejoin="round" /> ); })} {hover !== null && ( <g pointerEvents="none"> <line x1={x(hover)} x2={x(hover)} y1={PAD.top} y2={PAD.top + innerH} stroke="var(--muted-foreground)" strokeOpacity={0.4} /> {shown.map((s) => <circle key={s.key} cx={x(hover)} cy={y(s.values[hover])} r={4} fill={s.color} stroke="var(--background)" strokeWidth={2} />)} </g> )} </svg> )} {hover !== null && !loading && ( <div className="pointer-events-none absolute top-1 z-10 min-w-36 rounded-md border border-border bg-popover p-2.5 text-xs shadow-elev-2" style={{ left: Math.min(x(hover) + 12, width - 170) }}> <p className="mb-1 text-muted-foreground">{labels[hover]}</p> {shown.map((s) => ( <p key={s.key} className="flex items-center gap-2 tabular-nums"> <span className="size-2 rounded-full" style={{ background: s.color }} /> <span className="flex-1">{s.label}</span> {formatValue(s.values[hover])} </p> ))} </div> )} </div> </div> ); }
Usage
Import it from @/components/ui/line-chart after installing.
example.tsx
import { LineChart } from "@/components/ui/line-chart";
export function Latency() {
return (
<LineChart
labels={["Mon", "Tue", "Wed"]}
series={[
{ key: "p50", label: "p50", color: "var(--chart-1)", values: [120, 118, 125] },
{ key: "p95", label: "p95", color: "var(--chart-3)", values: [310, 290, 330], dashed: true },
]}
/>
);
}API reference
LineChart
Multiple lines with a toggleable legend and shared tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
| labels / series | string[] / { key; label; color; values; dashed? }[] | – | Data. |
| height | number | 260 | Height. |
| legend | boolean | true | Clickable legend. |
| loading / formatValue | boolean / (n) => string | – | Skeleton and number format. |
Keyboard interactions
| Keys | Action |
|---|---|
| Tab | Reach legend toggles |
| EnterorSpace | Show or hide a series |
Accessibility
- role="img"; legend items are toggle buttons with aria-pressed.
- Dashed lines help tell series apart without color.
Motion
- Lines draw in and morph between datasets.
Responsive behavior
- Measures its width; labels thin out.
Performance
- One path per series.
Notes for AI
- All series must have labels.length values.
This page as Markdown, for assistants and coding agents:
/components/line-chart/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt