Area chart
Free · MITArea chart that draws its line in on view, morphs between 1W / 1M / 1Y ranges, and rolls its headline value as a hairline and tooltip follow the pointer or the arrow keys.
Installation
$ npx shadcn@latest add https://goodui.dev/r/area-chart.jsonThe file lands in components/goodui/area-chart.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/area-chart works too.
Usage
import { AreaChart } from "@/components/goodui/area-chart"
const ranges = [
{ key: "1w", label: "1W", data: [{ label: "Mon", value: 1480 }, { label: "Tue", value: 1720 } /* … */] },
{ key: "1m", label: "1M", data: days.map((d) => ({ label: d.label, value: d.revenue })) },
{ key: "1y", label: "1Y", data: months.map((m) => ({ label: m.label, value: m.revenue })) },
]
export function RevenueCard() {
return (
<div className="rounded-xl border bg-card p-5">
<AreaChart ranges={ranges} defaultRange="1m" title="Revenue" description="this period" format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} height={220} />
</div>
)
}When to use
- One metric over time with a headline that matters: revenue, bookings, visits, MRR.
- A time-range picker (1W / 1M / 1Y) where the curve should morph instead of flashing a new chart.
- Dashboards where the number should follow the pointer: hover or arrow through the points and the headline rolls to each value.
- Several series on one axis: use line-chart, which has a legend and a crosshair that reads every series.
- A trend under a KPI with no axes: use sparkline-draw or kpi-trend.
- Values that are not a magnitude from zero (temperatures, scores): an area fill implies volume, so use line-chart with baseline="auto".
| If you need | Use |
|---|---|
| Revenue card with ranges | ranges={[{ key, label, data }]} with format={{ style: "currency", currency: "USD" }} |
| Headline shows the latest value, not the sum | headline="last" (or "average", or "none") |
| Exact readings matter more than looks | curve="linear" |
| Compact card without axis chrome | axis={false} grid={false} height={150} |
| Percentages | formatValue={(v) => `${v}%`} formatTick={(v) => `${v}%`} |
Examples
Single series, headline on the latest point
<AreaChart data={bookings} title="Bookings" headline="last" description="today" color="var(--chart-2)" height={150} axis={false} grid={false} />Controlled range (the app owns the selection)
const [range, setRange] = React.useState("1m")
<AreaChart ranges={ranges} range={range} onRangeChange={setRange} title="Visits" />Translated
<AreaChart
data={data}
title="Umsatz"
locale="de-DE"
format={{ style: "currency", currency: "EUR" }}
labels={{ range: "Zeitraum", columnLabel: "Zeitraum", columnValue: "Wert", noData: "Keine Daten", point: (label, value) => `${label}: ${value}` }}
/>API reference
| Prop | Type | Default |
|---|---|---|
| color Any CSS color for the line and gradient. Defaults to the theme's first chart color. | string | "var(--chart-1)" |
| data Points in order, oldest first. Changing them morphs the curve to the new shape. | AreaPoint[] | — |
| ranges Named data sets with a built-in picker (1W / 1M / 1Y); the curve morphs between them. Takes precedence over data. | AreaRange[] | — |
| range Selected range key (controlled). | string | — |
| defaultRange Initial range key when uncontrolled; defaults to the first. | string | — |
| onRangeChange | (key: string) => void | — |
| title Metric name above the headline, e.g. "Revenue". | React.ReactNode | — |
| headline What the rolling headline shows while nobody is scrubbing: the sum, the latest point, the average, or nothing. | "total" | "last" | "average" | "none" | "total" |
| description Muted text beside the headline, replaced by the hovered point's label while scrubbing. | React.ReactNode | — |
| height Plot height in px, axes included. The width follows the container. | number | 220 |
| curve smooth: monotone curve that never overshoots the data. linear: straight segments. | "smooth" | "linear" | "smooth" |
| grid Horizontal hairline gridlines. | boolean | true |
| axis Value ticks on the start side. | boolean | true |
| axisWidth Space reserved for the value ticks, in px. | number | 44 |
| format Intl.NumberFormat options for the headline, tooltip and ticks, e.g. { style: "currency", currency: "USD" }. | Intl.NumberFormatOptions | — |
| locale | string | "en-US" |
| formatValue Replaces the default formatter for the tooltip, table and screen readers. | (value: number) => string | — |
| formatTick Replaces the default tick formatter (compact above 10,000). | (value: number) => string | — |
| samples Resampled points along the curve; a constant count is what lets 7 and 365 points morph into each other. | number | 160 |
| duration Seconds the line takes to draw in. | number | 1.1 |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<AreaChartLabels> | — |
Also accepts every <div> attribute.
Accessibility
| Tab | Focuses the plot and highlights the latest point. |
| ArrowLeft / ArrowRight | Moves the hairline one point; the headline rolls to that value. |
| Home / End | Jumps to the first or last point. |
| Escape | Clears the highlight and tooltip. |
| Arrow keys in the range picker | Selects the previous or next range (radio group). |
- The plot is role="group" with aria-roledescription="area chart" and an aria-label built from the data ("Revenue: from $1,480 to $2,110, low …, high …"); your aria-label replaces it.
- A screen-reader-only table lists every point (period, value) under the chart; the SVG itself is aria-hidden.
- The highlighted point is announced through a polite live region as "<label>: <value>".
- The range picker is a radiogroup of real buttons with aria-checked and roving tabindex.
- All built-in strings (summary, point, range, column headers, noData) go through labels.
Motion
- On first view (IntersectionObserver, 20% visible) the line draws in with pathLength over duration (1.1 s, ease [0.65, 0, 0.35, 1]); the gradient area fades in over 0.7 s from 40% of that; the end dot pops on a spring (stiffness 420, damping 22) at 85%; x labels fade in left to right.
- Switching ranges or changing data morphs the path on a spring (stiffness 190, damping 30, mass 0.9). Every data set is resampled to `samples` points (160), so 7 and 365 points share one path shape; y ticks cross-fade and slide to their new positions.
- The hairline and active dot follow the pointer on a stiff spring (stiffness 600, damping 40); the headline digits roll with NumberFlow.
- A container resize re-lays the chart out instantly (no morph), and x labels that appear after the entrance fade in over 0.2 s without the stagger.
- The end dot sends out a ring every 2.4 s (CSS).
- Reduced motion: everything appears at its final state at once, morphs are instant, the ring is hidden; the gate waits for mount so the first client render matches the server.
- One ResizeObserver per chart measures the width before paint; the SVG is rendered only after measuring. Resampling runs once per data change and the path strings are short (two numbers per sample).
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Give ranges or data, not both; ranges wins. Each range's data is its own array of { label, value } in time order.
- format takes Intl.NumberFormat options and is used for the headline, tooltip and ticks; use formatValue/formatTick for custom strings (e.g. percentages).
- The width follows the parent; set height in px through the prop, not a Tailwind class.
- color takes any CSS color or variable (default var(--chart-1)); do not pass a Tailwind class.
- Pass a string title so the screen reader summary and table caption name the metric.
FAQ
Why does a 365-point range look slightly smoothed?
Each data set is resampled to `samples` points (160 by default) so the morph works; raise samples for dense daily data.
Does the smooth curve invent peaks?
No. It uses monotone cubic interpolation (Fritsch–Carlson), which never goes above a peak or below a dip in your data.
What renders on the server?
The header and an empty plot of the right height; the SVG renders after the width is measured, and the entrance plays once the chart is in view.
Related
Multi-series lines that draw in staggered, a legend that toggles series while the scale refits, a crosshair that reads every series at once, and a dashed forecast segment behind a divider.
Smooth monotone sparkline that draws itself in, fades in a gradient area, shows point values on hover or arrow keys, and redraws when the data changes.
KPI tile family: the number rolls in on view and on every change (NumberFlow), the delta badge pops and pulses, the sparkline draws itself (SparklineDraw), and a group lines tiles up with hairline dividers.
Digits roll to the new value with locale formatting, prefixes and suffixes.