Chart Line
Original · freeSVG line chart with points and grid
byWeb InnoventixReact + Tailwind
chartlinecharts
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chart-line.jsonchart-line.tsx
"use client"
import { useMemo, useRef, useState } from "react"
import type { KeyboardEvent, PointerEvent } from "react"
import { useReducedMotion } from "motion/react"
const VIEW_W = 760
const VIEW_H = 340
const PAD = { top: 30, right: 26, bottom: 46, left: 54 }
const PLOT_W = VIEW_W - PAD.left - PAD.right
const PLOT_H = VIEW_H - PAD.top - PAD.bottom
const Y_MAX = 80
const Y_TICKS = [0, 20, 40, 60, 80] as const
const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
] as const
const N = MONTHS.length
type SeriesId = "organic" | "direct"
interface Series {
id: SeriesId
label: string
values: number[]
strokeClass: string
areaClass: string
dotClass: string
swatchClass: string
}
const SERIES: Series[] = [
{
id: "organic",
label: "Organic search",
values: [12, 18, 22, 29, 34, 41, 47, 54, 60, 57, 66, 73],
strokeClass: "stroke-indigo-500 dark:stroke-indigo-400",
areaClass: "fill-indigo-500/10 dark:fill-indigo-400/10",
dotClass: "fill-indigo-500 dark:fill-indigo-400",
swatchClass: "bg-indigo-500 dark:bg-indigo-400",
},
{
id: "direct",
label: "Direct & referral",
values: [8, 9, 11, 14, 15, 19, 22, 24, 27, 26, 30, 33],
strokeClass: "stroke-emerald-500 dark:stroke-emerald-400",
areaClass: "fill-emerald-500/10 dark:fill-emerald-400/10",
dotClass: "fill-emerald-500 dark:fill-emerald-400",
swatchClass: "bg-emerald-500 dark:bg-emerald-400",
},
]
const xFor = (i: number) => PAD.left + (i / (N - 1)) * PLOT_W
const yFor = (v: number) => PAD.top + PLOT_H - (v / Y_MAX) * PLOT_H
function linePath(values: number[]): string {
return values
.map((v, i) => `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(2)} ${yFor(v).toFixed(2)}`)
.join(" ")
}
function areaPath(values: number[]): string {
const base = yFor(0).toFixed(2)
const top = values
.map((v, i) => `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(2)} ${yFor(v).toFixed(2)}`)
.join(" ")
return `${top} L ${xFor(N - 1).toFixed(2)} ${base} L ${xFor(0).toFixed(2)} ${base} Z`
}
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
const LATEST = SERIES[0].values[N - 1]
const MOM = ((SERIES[0].values[N - 1] - SERIES[0].values[N - 2]) / SERIES[0].values[N - 2]) * 100
export default function ChartLine() {
const reduced = useReducedMotion()
const animate = !reduced
const containerRef = useRef<HTMLDivElement>(null)
const [active, setActive] = useState<number | null>(null)
const [visible, setVisible] = useState<Record<SeriesId, boolean>>({
organic: true,
direct: true,
})
const visibleSeries = useMemo(() => SERIES.filter((s) => visible[s.id]), [visible])
const toggle = (id: SeriesId) =>
setVisible((v) => {
const next = { ...v, [id]: !v[id] }
if (!next.organic && !next.direct) return v
return next
})
const moveTo = (i: number) => setActive(clamp(i, 0, N - 1))
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
e.preventDefault()
moveTo((active ?? -1) + 1)
break
case "ArrowLeft":
case "ArrowDown":
e.preventDefault()
moveTo((active ?? N) - 1)
break
case "Home":
e.preventDefault()
moveTo(0)
break
case "End":
e.preventDefault()
moveTo(N - 1)
break
case "Escape":
if (active !== null) {
e.preventDefault()
setActive(null)
}
break
}
}
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const el = containerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const px = ((e.clientX - rect.left) / rect.width) * VIEW_W
let nearest = 0
let best = Infinity
for (let i = 0; i < N; i++) {
const d = Math.abs(xFor(i) - px)
if (d < best) {
best = d
nearest = i
}
}
setActive(nearest)
}
const activeIdx = active
const anchorY =
activeIdx !== null && visibleSeries.length > 0
? Math.min(...visibleSeries.map((s) => yFor(s.values[activeIdx])))
: 0
return (
<section className="relative w-full bg-white px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-20 lg:px-8">
<style>{`
@keyframes chartline-draw {
from { stroke-dashoffset: 1; }
to { stroke-dashoffset: 0; }
}
@keyframes chartline-pop {
from { opacity: 0; transform: scale(0.2); }
to { opacity: 1; transform: scale(1); }
}
@keyframes chartline-rise {
from { opacity: 0; }
to { opacity: 1; }
}
.chartline-line {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: chartline-draw 1.15s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.chartline-area {
opacity: 0;
animation: chartline-rise 0.9s ease-out 0.5s forwards;
}
.chartline-dot {
transform-box: fill-box;
transform-origin: center;
animation: chartline-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}
@media (prefers-reduced-motion: reduce) {
.chartline-line, .chartline-area, .chartline-dot {
animation: none;
stroke-dashoffset: 0;
opacity: 1;
}
}
`}</style>
<div className="mx-auto max-w-3xl">
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-7">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
Acquisition report
</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-2xl">
Sessions by channel
</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Thousands of sessions per month, July 2025 – June 2026
</p>
</div>
<div className="flex items-baseline gap-2 rounded-xl bg-slate-50 px-3.5 py-2.5 dark:bg-slate-800/60">
<span className="text-2xl font-semibold tabular-nums text-slate-900 dark:text-white">
{LATEST}k
</span>
<span className="inline-flex items-center gap-1 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden="true" fill="none">
<path
d="M6 10V2M6 2L2.5 5.5M6 2l3.5 3.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{MOM.toFixed(1)}%
</span>
</div>
</div>
{/* Legend / series toggles */}
<div className="mt-5 flex flex-wrap gap-2">
{SERIES.map((s) => {
const on = visible[s.id]
return (
<button
key={s.id}
type="button"
onClick={() => toggle(s.id)}
aria-pressed={on}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
on
? "border-slate-300 bg-slate-100 text-slate-800 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
: "border-slate-200 bg-transparent text-slate-400 dark:border-slate-700 dark:text-slate-500"
}`}
>
<span
className={`h-2.5 w-2.5 rounded-full transition-opacity ${s.swatchClass} ${
on ? "opacity-100" : "opacity-40"
}`}
/>
<span className={on ? "" : "line-through decoration-slate-400/70"}>
{s.label}
</span>
</button>
)
})}
</div>
{/* Chart */}
<div
ref={containerRef}
role="group"
tabIndex={0}
aria-label="Sessions by channel line chart. Use the left and right arrow keys to inspect each month."
aria-describedby="chartline-readout"
onKeyDown={onKeyDown}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
className="relative mt-4 w-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}`, touchAction: "none" }}
>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="h-full w-full overflow-visible"
role="presentation"
>
{/* Horizontal grid + y labels */}
{Y_TICKS.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PAD.left}
y1={y}
x2={VIEW_W - PAD.right}
y2={y}
className="stroke-slate-200 dark:stroke-slate-700/60"
strokeWidth={1}
/>
<text
x={PAD.left - 12}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-slate-400 dark:fill-slate-500"
fontSize={11}
>
{t}k
</text>
</g>
)
})}
{/* Vertical crosshair */}
{activeIdx !== null && (
<line
x1={xFor(activeIdx)}
y1={PAD.top - 4}
x2={xFor(activeIdx)}
y2={VIEW_H - PAD.bottom}
className="stroke-slate-300 dark:stroke-slate-600"
strokeWidth={1}
strokeDasharray="4 4"
/>
)}
{/* Series: area + line + points */}
{visibleSeries.map((s) => (
<g key={s.id}>
<path
d={areaPath(s.values)}
className={`${s.areaClass} ${animate ? "chartline-area" : ""}`}
/>
<path
d={linePath(s.values)}
fill="none"
pathLength={1}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className={`${s.strokeClass} ${animate ? "chartline-line" : ""}`}
/>
{s.values.map((v, i) => {
const on = activeIdx === i
return (
<circle
key={i}
cx={xFor(i)}
cy={yFor(v)}
r={on ? 5 : 3.5}
strokeWidth={on ? 2.5 : 1.5}
className={`${s.dotClass} stroke-white transition-[r] dark:stroke-slate-900 ${
animate ? "chartline-dot" : ""
}`}
style={animate ? { animationDelay: `${0.45 + i * 0.05}s` } : undefined}
/>
)
})}
</g>
))}
{/* X labels */}
{MONTHS.map((m, i) => (
<text
key={m}
x={xFor(i)}
y={VIEW_H - PAD.bottom + 22}
textAnchor="middle"
fontSize={11}
className={
activeIdx === i
? "fill-slate-900 font-semibold dark:fill-slate-100"
: "fill-slate-400 dark:fill-slate-500"
}
>
{m}
</text>
))}
</svg>
{/* Tooltip */}
{activeIdx !== null && visibleSeries.length > 0 && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full"
style={{
left: `${clamp((xFor(activeIdx) / VIEW_W) * 100, 9, 91)}%`,
top: `${(anchorY / VIEW_H) * 100}%`,
}}
>
<div className="mb-3 min-w-[9.5rem] rounded-xl border border-slate-200 bg-white/95 px-3 py-2.5 shadow-lg backdrop-blur dark:border-slate-700 dark:bg-slate-800/95">
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400">
{MONTHS[activeIdx]}{" "}
{activeIdx >= 6 ? "2025" : "2026"}
</p>
<ul className="mt-1.5 space-y-1">
{visibleSeries.map((s) => (
<li key={s.id} className="flex items-center gap-2 text-sm">
<span className={`h-2 w-2 shrink-0 rounded-full ${s.swatchClass}`} />
<span className="text-slate-600 dark:text-slate-300">{s.label}</span>
<span className="ml-auto font-semibold tabular-nums text-slate-900 dark:text-white">
{s.values[activeIdx]}k
</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
{/* Live readout */}
<p
id="chartline-readout"
aria-live="polite"
className="mt-3 text-center text-sm text-slate-500 dark:text-slate-400"
>
{activeIdx === null
? "Hover the chart or use the arrow keys to inspect a month."
: `${MONTHS[activeIdx]} ${activeIdx >= 6 ? "2025" : "2026"} — ${visibleSeries
.map((s) => `${s.label}: ${s.values[activeIdx]}k sessions`)
.join(", ")}.`}
</p>
{/* Screen-reader data table */}
<table className="sr-only">
<caption>Monthly sessions by channel, in thousands.</caption>
<thead>
<tr>
<th scope="col">Month</th>
{SERIES.map((s) => (
<th key={s.id} scope="col">
{s.label}
</th>
))}
</tr>
</thead>
<tbody>
{MONTHS.map((m, i) => (
<tr key={m}>
<th scope="row">
{m} {i >= 6 ? "2025" : "2026"}
</th>
{SERIES.map((s) => (
<td key={s.id}>{s.values[i]}k</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Heatmap
Originalcontribution-style heatmap grid

Chart Bubble
OriginalSVG bubble/scatter chart

