Area chart

Free · MIT

Area 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.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/area-chart.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Revenue card with rangesranges={[{ key, label, data }]} with format={{ style: "currency", currency: "USD" }}
Headline shows the latest value, not the sumheadline="last" (or "average", or "none")
Exact readings matter more than lookscurve="linear"
Compact card without axis chromeaxis={false} grid={false} height={150}
PercentagesformatValue={(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

PropTypeDefault
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.
number220
curve
smooth: monotone curve that never overshoots the data. linear: straight segments.
"smooth" | "linear""smooth"
grid
Horizontal hairline gridlines.
booleantrue
axis
Value ticks on the start side.
booleantrue
axisWidth
Space reserved for the value ticks, in px.
number44
format
Intl.NumberFormat options for the headline, tooltip and ticks, e.g. { style: "currency", currency: "USD" }.
Intl.NumberFormatOptions—
localestring"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.
number160
duration
Seconds the line takes to draw in.
number1.1
labels
Text for built-in controls and screen readers; pass translations here.
Partial<AreaChartLabels>—

Also accepts every <div> attribute.

Accessibility

TabFocuses the plot and highlights the latest point.
ArrowLeft / ArrowRightMoves the hairline one point; the headline rolls to that value.
Home / EndJumps to the first or last point.
EscapeClears the highlight and tooltip.
Arrow keys in the range pickerSelects 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