Skip to content
goodtabs UI
BrowseBrowse components

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

Weekly installs, in thousands
PeriodValue
Jun12k
Jul18k
Aug15k
Sep24k
Oct31k
Nov38k

Six points with a unit on the axis and the last one marked.

Captioned year

Build minutes per month
Build minutes per month
PeriodValue
Jan420
Feb562
Mar576
Apr523
May560
Jun714
Jul840
Aug834
Sep785
Oct846
Nov1008
Dec1114

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

PropTypeDefaultDescription
data{ label: string; value: number }[]One point per label, left to right.
titlestringWhat the chart shows, for the text table and the caption.
highlightnumberlastIndex of the point to mark in the brand colour. -1 for none.
format(value: number) => stringStringFormats axis labels and the text table.
captionbooleanfalseShow 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.