Chart Area
Original · freeSVG area chart with gradient fill
byWeb InnoventixReact + Tailwind
chartareacharts
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-area.jsonchart-area.tsx
"use client";
import { useId, useMemo, useState } from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type Metric = {
id: string;
name: string;
unit: "" | "$";
accent: string;
dot: string;
data: number[];
};
const MONTHS = [
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
];
const METRICS: Metric[] = [
{
id: "visitors",
name: "Unique visitors",
unit: "",
accent: "text-indigo-600 dark:text-indigo-400",
dot: "bg-indigo-500",
data: [
18420, 21030, 19850, 24310, 27690, 25120, 30240, 33110, 31580, 38260,
41920, 46370,
],
},
{
id: "signups",
name: "New signups",
unit: "",
accent: "text-emerald-600 dark:text-emerald-400",
dot: "bg-emerald-500",
data: [412, 468, 455, 531, 604, 572, 690, 742, 715, 861, 940, 1058],
},
{
id: "revenue",
name: "MRR",
unit: "$",
accent: "text-amber-600 dark:text-amber-500",
dot: "bg-amber-500",
data: [
8420, 9310, 8990, 11250, 13400, 12180, 15230, 16780, 16050, 19640, 21870,
24510,
],
},
];
const W = 768;
const H = 340;
const PAD_L = 54;
const PAD_R = 20;
const PAD_T = 20;
const PAD_B = 32;
const PLOT_X0 = PAD_L;
const PLOT_X1 = W - PAD_R;
const PLOT_W = PLOT_X1 - PLOT_X0;
const PLOT_H = H - PAD_T - PAD_B;
const BASELINE = PAD_T + PLOT_H;
const N = MONTHS.length;
const KEYFRAMES = `
@keyframes caz-pulse {
0% { transform: scale(1); opacity: .5; }
70% { transform: scale(2.6); opacity: 0; }
100% { transform: scale(2.6); opacity: 0; }
}
@keyframes caz-tip-in { from { opacity: 0; } to { opacity: 1; } }
.caz-pulse-ring {
animation: caz-pulse 2.4s ease-out infinite;
transform-box: fill-box;
transform-origin: center;
}
.caz-tip { animation: caz-tip-in .18s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.caz-pulse-ring, .caz-tip { animation: none !important; }
}
`;
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
function fmt(metric: Metric, v: number): string {
const s = Math.round(v).toLocaleString("en-US");
return metric.unit === "$" ? `$${s}` : s;
}
function tickLabel(metric: Metric, v: number): string {
const base = v >= 1000 ? `${v / 1000}k` : `${v}`;
if (metric.unit === "$") return v === 0 ? "$0" : `$${base}`;
return base;
}
export default function ChartArea() {
const reduce = useReducedMotion();
const uid = useId();
const gradId = `${uid}-grad`;
const [metricId, setMetricId] = useState<string>("visitors");
const [active, setActive] = useState<number>(N - 1);
const [show, setShow] = useState<boolean>(false);
const metric = useMemo<Metric>(
() => METRICS.find((m) => m.id === metricId) ?? METRICS[0],
[metricId],
);
const yMax = useMemo<number>(() => {
const max = Math.max(...metric.data);
const mag = Math.pow(10, Math.floor(Math.log10(max)));
return Math.ceil(max / mag) * mag;
}, [metric]);
const geo = useMemo(() => {
const pts = metric.data.map((v, i) => ({
x: PLOT_X0 + (i / (N - 1)) * PLOT_W,
y: PAD_T + (1 - v / yMax) * PLOT_H,
v,
}));
const line = pts
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
.join(" ");
const area =
`M${pts[0].x.toFixed(2)},${BASELINE} ` +
pts.map((p) => `L${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ") +
` L${pts[N - 1].x.toFixed(2)},${BASELINE} Z`;
return { pts, line, area };
}, [metric, yMax]);
const stats = useMemo(() => {
const d = metric.data;
const total = d.reduce((a, b) => a + b, 0);
const cur = d[N - 1];
const prev = d[N - 2];
return {
latest: cur,
peak: Math.max(...d),
avg: total / N,
total,
deltaPct: ((cur - prev) / prev) * 100,
};
}, [metric]);
const up = stats.deltaPct >= 0;
const activePt = geo.pts[active];
const tipLeft = clamp((activePt.x / W) * 100, 12, 88);
const topPct = (activePt.y / H) * 100;
function handlePointer(e: ReactPointerEvent<HTMLDivElement>): void {
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width === 0) return;
const px = ((e.clientX - rect.left) / rect.width) * W;
const frac = (px - PLOT_X0) / PLOT_W;
setActive(Math.round(clamp(frac, 0, 1) * (N - 1)));
}
function handleKey(e: ReactKeyboardEvent<HTMLDivElement>): void {
let next = active;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = Math.min(N - 1, active + 1);
break;
case "ArrowLeft":
case "ArrowDown":
next = Math.max(0, active - 1);
break;
case "Home":
next = 0;
break;
case "End":
next = N - 1;
break;
case "PageUp":
next = Math.min(N - 1, active + 3);
break;
case "PageDown":
next = Math.max(0, active - 3);
break;
default:
return;
}
e.preventDefault();
setActive(next);
}
const grid = [0, 0.25, 0.5, 0.75, 1];
return (
<section className="relative w-full bg-slate-50 py-16 dark:bg-slate-950 sm:py-20">
<style>{KEYFRAMES}</style>
<div className="mx-auto w-full max-w-3xl px-4 sm:px-6">
<figure className="m-0 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm ring-1 ring-slate-900/[0.02] dark:border-slate-800 dark:bg-slate-900 dark:ring-white/[0.02]">
{/* Header */}
<div className="flex flex-col gap-5 border-b border-slate-100 px-6 pb-6 pt-6 dark:border-slate-800 sm:px-8 sm:pt-7">
<div className="flex items-start justify-between gap-4">
<div>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
aria-hidden="true"
>
<path
d="M1.5 10.5 5 7l2.5 2L14 3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M1.5 10.5 5 7l2.5 2L14 3v10.5H1.5z"
fill="currentColor"
fillOpacity="0.12"
/>
</svg>
Growth report
</span>
<h2 className="mt-1.5 text-lg font-semibold tracking-tight text-slate-900 dark:text-white sm:text-xl">
Product performance
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
Trailing 12 months · through Jun 2026
</p>
</div>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold ${
up
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400"
: "bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400"
}`}
>
<svg
viewBox="0 0 12 12"
className={`h-3 w-3 ${up ? "" : "rotate-180"}`}
fill="none"
aria-hidden="true"
>
<path
d="M6 9.5V2.5M6 2.5 3 5.5M6 2.5l3 3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{up ? "+" : ""}
{stats.deltaPct.toFixed(1)}%
<span className="sr-only"> versus previous month</span>
</span>
</div>
<div className="flex items-end gap-3">
<span
className={`text-4xl font-semibold tracking-tight tabular-nums ${metric.accent}`}
>
{fmt(metric, stats.latest)}
</span>
<span className="pb-1 text-sm text-slate-500 dark:text-slate-400">
{metric.name.toLowerCase()} in June
</span>
</div>
{/* Metric selector */}
<div
role="group"
aria-label="Select metric to chart"
className="inline-flex flex-wrap gap-1 rounded-xl bg-slate-100 p-1 dark:bg-slate-800/70"
>
{METRICS.map((m) => {
const on = m.id === metricId;
return (
<button
key={m.id}
type="button"
aria-pressed={on}
onClick={() => setMetricId(m.id)}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-800 ${
on
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
<span
className={`h-2 w-2 rounded-full ${m.dot} ${
on ? "" : "opacity-40"
}`}
aria-hidden="true"
/>
{m.name}
</button>
);
})}
</div>
</div>
{/* Chart */}
<div className="px-3 pb-2 pt-5 sm:px-5">
<div
className="relative w-full"
style={{ aspectRatio: `${W} / ${H}` }}
>
<svg
viewBox={`0 0 ${W} ${H}`}
className={`absolute inset-0 h-full w-full transition-colors duration-500 ${metric.accent}`}
role="img"
aria-label={`Area chart of ${metric.name.toLowerCase()} over the last 12 months.`}
preserveAspectRatio="none"
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.34" />
<stop offset="55%" stopColor="currentColor" stopOpacity="0.12" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.02" />
</linearGradient>
</defs>
{/* Gridlines + y labels */}
{grid.map((f) => {
const y = PAD_T + (1 - f) * PLOT_H;
const labelled = f === 0 || f === 0.5 || f === 1;
return (
<g key={f}>
<line
x1={PLOT_X0}
y1={y}
x2={PLOT_X1}
y2={y}
className="stroke-slate-200 dark:stroke-slate-700/60"
strokeWidth={1}
strokeDasharray={f === 0 ? "0" : "3 5"}
/>
{labelled ? (
<text
x={PLOT_X0 - 10}
y={y + 3.5}
textAnchor="end"
className="fill-slate-400 text-[10px] tabular-nums dark:fill-slate-500"
>
{tickLabel(metric, yMax * f)}
</text>
) : null}
</g>
);
})}
{/* Area fill */}
<motion.path
key={`fill-${metric.id}`}
d={geo.area}
fill={`url(#${gradId})`}
stroke="none"
initial={reduce ? false : { opacity: 0 }}
animate={reduce ? undefined : { opacity: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
{/* Line */}
<motion.path
key={`line-${metric.id}`}
d={geo.line}
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? false : { pathLength: 0 }}
animate={reduce ? undefined : { pathLength: 1 }}
transition={{ duration: 1.1, ease: "easeInOut" }}
vectorEffect="non-scaling-stroke"
/>
{/* Crosshair + active marker */}
<line
x1={activePt.x}
y1={PAD_T}
x2={activePt.x}
y2={BASELINE}
stroke="currentColor"
strokeWidth={1}
strokeDasharray="3 4"
strokeOpacity={0.5}
/>
{!reduce ? (
<circle
className="caz-pulse-ring"
cx={activePt.x}
cy={activePt.y}
r={6}
fill="currentColor"
/>
) : null}
<circle
cx={activePt.x}
cy={activePt.y}
r={4.5}
className="fill-white dark:fill-slate-900"
stroke="currentColor"
strokeWidth={2.5}
vectorEffect="non-scaling-stroke"
/>
{/* x labels */}
{geo.pts.map((p, i) => (
<text
key={MONTHS[i]}
x={p.x}
y={H - 10}
textAnchor="middle"
className={`text-[10px] tabular-nums ${
i === active
? "fill-slate-700 font-semibold dark:fill-slate-200"
: "fill-slate-400 dark:fill-slate-500"
}`}
>
{MONTHS[i]}
</text>
))}
</svg>
{/* Tooltip */}
{show ? (
<div
className="caz-tip pointer-events-none absolute z-10 whitespace-nowrap rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-slate-700 dark:bg-slate-800"
style={{
left: `${tipLeft}%`,
top: `${topPct}%`,
transform: "translate(-50%, calc(-100% - 12px))",
}}
>
<div className="flex items-center gap-1.5 font-medium text-slate-500 dark:text-slate-400">
<span
className={`h-2 w-2 rounded-full ${metric.dot}`}
aria-hidden="true"
/>
{MONTHS[active]} 2026
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
{fmt(metric, activePt.v)}
</div>
</div>
) : null}
{/* Interactive scrubber */}
<div
role="slider"
tabIndex={0}
aria-label={`${metric.name} by month. Use arrow keys to inspect each of the last 12 months.`}
aria-valuemin={0}
aria-valuemax={N - 1}
aria-valuenow={active}
aria-valuetext={`${MONTHS[active]}: ${fmt(metric, activePt.v)}`}
onKeyDown={handleKey}
onPointerMove={handlePointer}
onPointerDown={handlePointer}
onPointerEnter={() => setShow(true)}
onPointerLeave={() => setShow(false)}
onFocus={() => setShow(true)}
onBlur={() => setShow(false)}
className="absolute inset-0 cursor-crosshair rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
/>
</div>
</div>
{/* Stats footer */}
<div className="grid grid-cols-2 gap-px overflow-hidden border-t border-slate-100 bg-slate-100 dark:border-slate-800 dark:bg-slate-800 sm:grid-cols-4">
{[
{ label: "Latest", value: fmt(metric, stats.latest) },
{ label: "Peak month", value: fmt(metric, stats.peak) },
{ label: "Avg / mo", value: fmt(metric, stats.avg) },
{ label: "12-mo total", value: fmt(metric, stats.total) },
].map((s) => (
<div
key={s.label}
className="bg-white px-5 py-4 dark:bg-slate-900"
>
<dt className="text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
{s.label}
</dt>
<dd className="mt-1 text-base font-semibold tabular-nums text-slate-900 dark:text-white">
{s.value}
</dd>
</div>
))}
</div>
<figcaption className="sr-only">
{metric.name} for the trailing 12 months ending June 2026.
<table>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">{metric.name}</th>
</tr>
</thead>
<tbody>
{metric.data.map((v, i) => (
<tr key={MONTHS[i]}>
<th scope="row">{MONTHS[i]} 2026</th>
<td>{fmt(metric, v)}</td>
</tr>
))}
</tbody>
</table>
</figcaption>
</figure>
</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 Line
OriginalSVG line chart with points and grid

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

