Area chart
A single-series area chart in plain SVG with hairline grid and one marked point.
npx shadcn@latest add https://ui.goodtabs.co/r/area-chart.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/area-chart
| Period | Value |
|---|---|
| Jun | 12k |
| Jul | 18k |
| Aug | 15k |
| Sep | 24k |
| Oct | 31k |
| Nov | 38k |
Six points with a unit on the axis and the last one marked.
Captioned year
| Period | Value |
|---|---|
| Jan | 420 |
| Feb | 562 |
| Mar | 576 |
| Apr | 523 |
| May | 560 |
| Jun | 714 |
| Jul | 840 |
| Aug | 834 |
| Sep | 785 |
| Oct | 846 |
| Nov | 1008 |
| Dec | 1114 |
Twelve months with a visible caption and a different point marked.
Usage
import { AreaChart } from "@/components/ui/area-chart"
<AreaChart title="Weekly installs" data={[{ label: "Jun", value: 12 }, { label: "Jul", value: 18 }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | { label: string; value: number }[] | One point per label, left to right. | |
| title | string | What the chart shows, for the text table and the caption. | |
| highlight | number | last | Index of the point to mark in the brand colour. -1 for none. |
| format | (value: number) => string | String | Formats axis labels and the text table. |
| caption | boolean | false | Show the title above the chart. |
Source
area-chart/area-chart.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
type Point = { label: string; value: number };
type AreaChartProps = Omit<React.ComponentProps<"figure">, "title"> & {
/** One point per label, left to right. */
data: Point[];
/** What the chart shows. Read by screen readers and shown as the caption when `caption` is set. */
title: string;
/** Index of the one point to mark with the brand dot. Defaults to the last. Pass -1 for none. */
highlight?: number;
/** Format a value for the axis and the text table, for example (v) => `${v}k`. */
format?: (value: number) => string;
/** Show the title as a visible caption above the chart. */
caption?: boolean;
};
const W = 320;
const H = 180;
const PAD = { top: 12, right: 12, bottom: 24, left: 32 };
/** Rounds the top of the scale up to 1, 2 or 5 times a power of ten, split into four steps. */
function niceScale(max: number) {
const raw = max / 4;
const pow = 10 ** Math.floor(Math.log10(raw || 1));
const step = [1, 2, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? pow * 10;
return { step, top: step * 4 };
}
/**
* An area chart in plain SVG: foreground line, a faint fill, hairline grid and one optional brand dot.
* Server component. A hidden table carries the numbers for screen readers.
*/
function AreaChart({ data, title, highlight, format = String, caption, className, ...props }: AreaChartProps) {
const { step, top } = niceScale(Math.max(...data.map((d) => d.value), 1));
const n = data.length;
const innerW = W - PAD.left - PAD.right;
const innerH = H - PAD.top - PAD.bottom;
const x = (i: number) => PAD.left + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const y = (v: number) => PAD.top + innerH - (v / top) * innerH;
const line = data.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)} ${y(d.value).toFixed(1)}`).join(" ");
const area = `${line} L${x(n - 1).toFixed(1)} ${PAD.top + innerH} L${x(0).toFixed(1)} ${PAD.top + innerH} Z`;
const mark = highlight === undefined ? n - 1 : highlight;
const every = Math.ceil(n / 6);
return (
<figure data-slot="area-chart" className={cn("m-0 w-full", className)} {...props}>
{caption && <figcaption className="mb-2 text-sm font-medium">{title}</figcaption>}
<svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full overflow-visible" aria-hidden="true">
{[0, 1, 2, 3, 4].map((t) => (
<g key={t}>
<line
x1={PAD.left}
x2={W - PAD.right}
y1={y(t * step)}
y2={y(t * step)}
className="stroke-border"
strokeWidth={1}
strokeDasharray={t === 0 ? undefined : "2 3"}
/>
<text
x={PAD.left - 6}
y={y(t * step)}
textAnchor="end"
dominantBaseline="middle"
className="fill-muted-foreground font-mono text-[9px]"
>
{format(t * step)}
</text>
</g>
))}
<path d={area} className="fill-foreground/8" />
<path
d={line}
className="fill-none stroke-foreground"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
{data.map((d, i) =>
i % every === 0 || i === n - 1 ? (
<text
key={d.label}
x={x(i)}
y={H - 6}
textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
className="fill-muted-foreground font-mono text-[9px]"
>
{d.label}
</text>
) : null,
)}
{data[mark] && (
<circle
cx={x(mark)}
cy={y(data[mark].value)}
r={3.5}
className="fill-brand stroke-background"
strokeWidth={2}
/>
)}
</svg>
{/* A table ignores the width and height sr-only sets, so the wrapper has to carry it. */}
<div className="sr-only">
<table>
<caption>{title}</caption>
<thead>
<tr>
<th scope="col">Period</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
{data.map((d) => (
<tr key={d.label}>
<th scope="row">{d.label}</th>
<td>{format(d.value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</figure>
);
}
export { AreaChart };
export type { Point as AreaChartPoint };
For AI agents
Hand your agent this page, the markdown at /components/area-chart/prompt.md, or the Copy AI prompt button above.
When to use
- One metric over time, where the shape matters more than exact values
- Dashboard cards and report sections next to a stat card
- Anywhere a chart library would be heavy for one line
When not to use
- Several series that need a legend, or negative values
- Dense data with hundreds of points, or hover readouts
- Comparing parts of a whole: use the donut chart
Agent notes
- Server component, no dependencies. It scales to its container width and keeps a 16:9-ish ratio
- Colours are foreground tones and hairlines; the one brand dot is a mark, not a series colour
- A visually hidden table repeats every value for screen readers, so title should say what and in which unit
- The axis top rounds up to 1, 2 or 5 times a power of ten; values are not clipped or smoothed
The maths
- Axis step is the smallest of 1, 2, 5 or 10 times 10^floor(log10(max / 4)) that is at least max / 4, so four steps always cover the data.
- A point's y is top + innerHeight - (value / niceTop) × innerHeight; x is left + i / (n - 1) × innerWidth.
- The area is the line path closed along the baseline, filled at 8 percent foreground so the stroke stays the loudest mark.