# Line Chart

> Responsive multi-series SVG line and area chart with smooth curves, hover crosshair tooltip, legend toggles and draw-in animation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/line-chart
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/line-chart.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/line-chart-demo.tsx"
import { LineChart } from "@/components/line-chart"

const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

const series = [
  { name: "Revenue", tone: "sky" as const, data: [18400, 22100, 19800, 27300, 31500, 29200, 36800, 39100, 37400, 44200, 48900, 53600] },
  { name: "Expenses", tone: "amber" as const, data: [14200, 15100, 15800, 17600, 18200, 19900, 21400, 22800, 23100, 24700, 26200, 27900] },
  { name: "Profit", tone: "emerald" as const, data: [4200, 7000, 4000, 9700, 13300, 9300, 15400, 16300, 14300, 19500, 22700, 25700] },
]

export default function LineChartDemo() {
  return (
    <div className="w-full max-w-2xl min-w-0">
      <div className="rounded-2xl border bg-card p-5 shadow-sm">
        <p className="text-sm text-muted-foreground">Financial overview, 2026</p>
        <p className="mb-5 text-2xl font-semibold tabular-nums">$53,600 <span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">+9.6% MoM</span></p>
        <LineChart labels={months} series={series} title="Revenue, expenses and profit by month" valuePrefix="$" height={260} />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/line-chart.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type LineChartTone = "primary" | "emerald" | "sky" | "violet" | "amber" | "rose"

export interface LineChartSeries {
  name: string
  data: number[]
  tone?: LineChartTone
}

export interface LineChartProps extends Omit<React.ComponentProps<"div">, "children"> {
  /** X axis labels, one per data point */
  labels: string[]
  series: LineChartSeries[]
  /** Plot height in px */
  height?: number
  curve?: "monotone" | "linear"
  /** Fill the area under each line */
  area?: boolean
  /** Accessible title used in the summary */
  title?: string
  valuePrefix?: string
  valueSuffix?: string
}

const toneText: Record<LineChartTone, string> = {
  primary: "text-primary",
  emerald: "text-emerald-500 dark:text-emerald-400",
  sky: "text-sky-500 dark:text-sky-400",
  violet: "text-violet-500 dark:text-violet-400",
  amber: "text-amber-500 dark:text-amber-400",
  rose: "text-rose-500 dark:text-rose-400",
}

const toneBg: Record<LineChartTone, string> = {
  primary: "bg-primary",
  emerald: "bg-emerald-500 dark:bg-emerald-400",
  sky: "bg-sky-500 dark:bg-sky-400",
  violet: "bg-violet-500 dark:bg-violet-400",
  amber: "bg-amber-500 dark:bg-amber-400",
  rose: "bg-rose-500 dark:bg-rose-400",
}

const toneOrder: LineChartTone[] = ["sky", "emerald", "violet", "amber", "rose", "primary"]

const MARGIN = { top: 12, right: 20, bottom: 28, left: 46 }
const TICKS = 4
const TIP_WIDTH = 168
const full = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 })
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 })

interface Pt {
  x: number
  y: number
}

function niceMax(value: number) {
  if (value <= 0) return 1
  const exp = 10 ** Math.floor(Math.log10(value))
  const f = value / exp
  const nice = [1, 2, 4, 6, 8, 10].find((n) => f <= n) ?? 10
  return nice * exp
}

const f1 = (n: number) => n.toFixed(1)

function linearPath(pts: Pt[]) {
  return pts.map((p, i) => `${i === 0 ? "M" : "L"}${f1(p.x)},${f1(p.y)}`).join(" ")
}

/** Monotone cubic interpolation (Fritsch-Butland): smooth, never overshoots the data. */
function monotonePath(pts: Pt[]) {
  const n = pts.length
  if (n < 3) return linearPath(pts)
  const dx: number[] = []
  const slope: number[] = []
  for (let i = 0; i < n - 1; i++) {
    dx.push(pts[i + 1].x - pts[i].x)
    slope.push((pts[i + 1].y - pts[i].y) / dx[i])
  }
  const tan: number[] = new Array(n).fill(0)
  tan[0] = slope[0]
  tan[n - 1] = slope[n - 2]
  for (let i = 1; i < n - 1; i++) {
    if (slope[i - 1] * slope[i] <= 0) {
      tan[i] = 0
    } else {
      tan[i] = (3 * (dx[i - 1] + dx[i])) / ((2 * dx[i] + dx[i - 1]) / slope[i - 1] + (dx[i] + 2 * dx[i - 1]) / slope[i])
    }
  }
  let d = `M${f1(pts[0].x)},${f1(pts[0].y)}`
  for (let i = 0; i < n - 1; i++) {
    const h = dx[i] / 3
    d += ` C${f1(pts[i].x + h)},${f1(pts[i].y + tan[i] * h)} ${f1(pts[i + 1].x - h)},${f1(pts[i + 1].y - tan[i + 1] * h)} ${f1(pts[i + 1].x)},${f1(pts[i + 1].y)}`
  }
  return d
}

const keyframes = `@keyframes atelier-line-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
@keyframes atelier-line-fade{from{opacity:0}to{opacity:1}}
@media (prefers-reduced-motion:reduce){.atelier-line-draw,.atelier-line-fade{animation:none!important}}`

export function LineChart({
  labels,
  series,
  height = 280,
  curve = "monotone",
  area = true,
  title = "Line chart",
  valuePrefix = "",
  valueSuffix = "",
  className,
  ...props
}: LineChartProps) {
  const uid = React.useId().replace(/[^\w-]/g, "")
  const [width, setWidth] = React.useState(0)
  const [hidden, setHidden] = React.useState<readonly string[]>([])
  const [hover, setHover] = React.useState<number | null>(null)

  // Width is measured by an observer attached from a ref callback, never during render.
  const measureRef = React.useCallback((node: HTMLDivElement | null) => {
    if (!node) return
    const observer = new ResizeObserver((entries) => {
      const w = Math.round(entries[0].contentRect.width)
      setWidth((prev) => (prev === w ? prev : w))
    })
    observer.observe(node)
    return () => observer.disconnect()
  }, [])

  const n = labels.length
  const fmt = (v: number) => `${valuePrefix}${full.format(v)}${valueSuffix}`
  const resolved = series.map((s, i) => ({ ...s, tone: s.tone ?? toneOrder[i % toneOrder.length] }))
  const visible = resolved.filter((s) => !hidden.includes(s.name))
  const max = niceMax(Math.max(0, ...resolved.flatMap((s) => s.data)))
  const ticks = Array.from({ length: TICKS + 1 }, (_, i) => (max / TICKS) * i)

  const plotW = Math.max(width - MARGIN.left - MARGIN.right, 10)
  const plotH = Math.max(height - MARGIN.top - MARGIN.bottom, 10)
  const xAt = (i: number) => (n <= 1 ? MARGIN.left + plotW / 2 : MARGIN.left + (i / (n - 1)) * plotW)
  const yAt = (v: number) => MARGIN.top + plotH - (Math.max(v, 0) / max) * plotH
  const labelStep = Math.max(1, Math.ceil((n * 48) / plotW))

  function onMove(e: React.PointerEvent<SVGRectElement>) {
    const rect = e.currentTarget.getBoundingClientRect()
    if (rect.width === 0 || n === 0) return
    const rel = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1)
    setHover(n <= 1 ? 0 : Math.round(rel * (n - 1)))
  }

  function toggle(name: string) {
    setHidden((cur) => (cur.includes(name) ? cur.filter((c) => c !== name) : [...cur, name]))
  }

  const summary = `${title}: ${resolved
    .map((s) => {
      const last = s.data[s.data.length - 1] ?? 0
      return `${s.name} from ${fmt(Math.min(...s.data))} to ${fmt(Math.max(...s.data))}, latest ${fmt(last)}`
    })
    .join("; ")}. ${n} points from ${labels[0] ?? ""} to ${labels[n - 1] ?? ""}.`

  const hoverX = hover !== null ? xAt(hover) : 0
  const flip = hoverX > width / 2
  const tipLeft = Math.min(Math.max(flip ? hoverX - 14 - TIP_WIDTH : hoverX + 14, 0), Math.max(width - TIP_WIDTH, 0))

  return (
    <div className={cn("w-full min-w-0", className)} {...props}>
      <style>{keyframes}</style>
      <div ref={measureRef} role="img" aria-label={summary} className="relative w-full" style={{ height }}>
        {width > 0 && (
          <svg width={width} height={height} className="block overflow-visible" aria-hidden>
            {ticks.map((t) => (
              <g key={t}>
                <line
                  x1={MARGIN.left}
                  x2={MARGIN.left + plotW}
                  y1={yAt(t)}
                  y2={yAt(t)}
                  className={t === 0 ? "stroke-border" : "stroke-border/70"}
                  strokeDasharray={t === 0 ? undefined : "4 4"}
                />
                <text x={MARGIN.left - 8} y={yAt(t)} textAnchor="end" dominantBaseline="central" className="fill-muted-foreground text-[11px]">
                  {valuePrefix}
                  {compact.format(t)}
                  {valueSuffix}
                </text>
              </g>
            ))}
            {labels.map((label, i) =>
              i % labelStep === 0 ? (
                <text
                  key={`${label}-${i}`}
                  x={xAt(i)}
                  y={height - 8}
                  textAnchor="middle"
                  className="fill-muted-foreground text-[11px]"
                >
                  {label}
                </text>
              ) : null
            )}
            {visible.map((s) => {
              const idx = resolved.findIndex((r) => r.name === s.name)
              const pts = s.data.map((v, i) => ({ x: xAt(i), y: yAt(v) }))
              if (pts.length === 0) return null
              const line = curve === "monotone" ? monotonePath(pts) : linearPath(pts)
              const base = MARGIN.top + plotH
              const areaPath = `${line} L${f1(pts[pts.length - 1].x)},${f1(base)} L${f1(pts[0].x)},${f1(base)} Z`
              const gid = `${uid}-g${idx}`
              return (
                <g key={s.name} className={toneText[s.tone]}>
                  {area && pts.length > 1 && (
                    <>
                      <defs>
                        <linearGradient id={gid} x1="0" x2="0" y1="0" y2="1">
                          <stop offset="0%" stopColor="currentColor" stopOpacity={0.22} />
                          <stop offset="100%" stopColor="currentColor" stopOpacity={0} />
                        </linearGradient>
                      </defs>
                      <path
                        d={areaPath}
                        fill={`url(#${gid})`}
                        className="atelier-line-fade"
                        style={{ animation: `atelier-line-fade 700ms ease-out ${300 + idx * 120}ms both` }}
                      />
                    </>
                  )}
                  <path
                    d={line}
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2.25}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    pathLength={1}
                    className="atelier-line-draw"
                    style={{
                      strokeDasharray: 1,
                      strokeDashoffset: 0,
                      animation: `atelier-line-draw 900ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 120}ms both`,
                    }}
                  />
                </g>
              )
            })}
            {hover !== null && (
              <g>
                <line x1={hoverX} x2={hoverX} y1={MARGIN.top} y2={MARGIN.top + plotH} className="stroke-muted-foreground/60" strokeDasharray="3 3" />
                {visible.map((s) =>
                  s.data[hover] === undefined ? null : (
                    <g key={s.name} className={toneText[s.tone]}>
                      <circle cx={hoverX} cy={yAt(s.data[hover])} r={4.5} strokeWidth={2.25} stroke="currentColor" className="fill-background" />
                    </g>
                  )
                )}
              </g>
            )}
            <rect
              x={MARGIN.left}
              y={MARGIN.top}
              width={plotW}
              height={plotH}
              fill="transparent"
              style={{ touchAction: "pan-y" }}
              onPointerMove={onMove}
              onPointerDown={onMove}
              onPointerLeave={() => setHover(null)}
              onPointerCancel={() => setHover(null)}
            />
          </svg>
        )}
        {hover !== null && width > 0 && (
          <div
            aria-hidden
            className="pointer-events-none absolute z-10 rounded-lg border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
            style={{ left: tipLeft, top: MARGIN.top, width: TIP_WIDTH }}
          >
            <p className="mb-1.5 font-medium">{labels[hover]}</p>
            <ul className="grid gap-1">
              {visible.map((s) => (
                <li key={s.name} className="flex items-center gap-2">
                  <span className={cn("size-2 shrink-0 rounded-full", toneBg[s.tone])} />
                  <span className="min-w-0 flex-1 truncate text-muted-foreground">{s.name}</span>
                  <span className="font-medium tabular-nums">{s.data[hover] === undefined ? "–" : fmt(s.data[hover])}</span>
                </li>
              ))}
              {visible.length === 0 && <li className="text-muted-foreground">No series selected</li>}
            </ul>
          </div>
        )}
      </div>
      <ul className="mt-3 flex flex-wrap gap-2" aria-label={`${title} series`}>
        {resolved.map((s) => {
          const on = !hidden.includes(s.name)
          return (
            <li key={s.name}>
              <button
                type="button"
                aria-pressed={on}
                onClick={() => toggle(s.name)}
                className={cn(
                  "inline-flex h-8 items-center gap-2 rounded-full border px-3 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40",
                  !on && "text-muted-foreground"
                )}
              >
                <span className={cn("size-2.5 rounded-full transition-opacity", toneBg[s.tone], !on && "opacity-30")} aria-hidden />
                <span className={cn(!on && "line-through")}>{s.name}</span>
              </button>
            </li>
          )
        })}
      </ul>
      <table className="sr-only">
        <caption>{title}</caption>
        <thead>
          <tr>
            <th scope="col">Label</th>
            {resolved.map((s) => (
              <th key={s.name} scope="col">
                {s.name}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {labels.map((label, i) => (
            <tr key={`${label}-${i}`}>
              <th scope="row">{label}</th>
              {resolved.map((s) => (
                <td key={s.name}>{s.data[i] === undefined ? "" : fmt(s.data[i])}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}
```
