# Area Chart

> Area chart for React and shadcn that draws in once, morphs to new data while its scale springs, and has a crosshair tooltip.

An area chart that draws in once and morphs to new data from the shape on screen while its scale springs. A crosshair glides between points, a tooltip trails it, and an optional dashed line compares a previous period.

- Tier: Free (MIT)
- Interaction: Hover
- Page: https://eased.dev/components/area-chart
- Installs: motion
- Registry: https://eased.dev/r/area-chart.json

## Install

```bash
npx shadcn add https://eased.dev/r/area-chart.json
```

One series filled down to zero, with gridlines, x labels, a scrubbable tooltip and an optional dashed line to compare against. Hand-rolled SVG on the free chart core (`d3-scale` and Motion), so paths are written to the DOM and nothing re-renders while you scrub or while it morphs.

## When to use

- A single trend over time where the volume matters, such as signups per week or requests per day.
- This period against the last one, as a filled area and a dashed line on the same scale.
- A metric whose range or source changes in place: switching from 3 months to a year morphs the area instead of redrawing it.

## When not to use

- A trend in a sentence or a table cell, with no axes: use a [Sparkline](/components/sparkline).
- Three or more series, legend toggles, rolling values in the tooltip, a loading state, or several charts that change range together: use [Line Chart](/components/line-chart) (Pro).
- Counts per category rather than over time: use [Bar Chart](/components/bar-chart).
- Values that can go negative, such as profit and loss: an area filled to zero reads as volume, so use [Line Chart](/components/line-chart) (Pro).

## Usage

```tsx weekly-signups.tsx
import { AreaChart } from "@/components/eased/area-chart/area-chart";

const weeks = [
  { week: "2026-09-21", signups: 1184 },
  { week: "2026-09-28", signups: 1243 },
  { week: "2026-10-05", signups: 1310 },
  { week: "2026-10-12", signups: 1276 },
];

const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" });

export function WeeklySignups() {
  return (
    <AreaChart
      data={weeks}
      xKey="week"
      series={{ key: "signups", label: "Signups" }}
      label="Signups per week"
      format={(v) => v.toLocaleString("en-US")}
      formatX={(k) => short.format(new Date(`${k}T00:00`))}
    />
  );
}
```

## Examples

### Against the previous period

Put both series on each datum and pass the second as `compare`. It draws as a dashed line at 40% in the same colour, with no fill, and a legend appears above the chart so the dash is never left to guess. Once shown, the legend row stays if `compare` is removed, so the plot doesn't jump while the dashed line flattens away.

```tsx
const data = weeks.map((w, i) => ({ week: w.week, signups: w.value, previous: lastPeriod[i].value }));

<AreaChart
  data={data}
  xKey="week"
  series={{ key: "signups", label: "This period" }}
  compare={{ key: "previous", label: "Previous period" }}
  label="Signups per week, against the 13 weeks before"
/>
```

### A headline that follows the crosshair

`onActiveChange` reports the scrubbed point, then `null` when scrubbing stops. Show the point's value while it is set and the total otherwise; with [Number Roll](/components/number-roll) the digits roll between them.

```tsx
const [active, setActive] = useState<number | null>(null);
const total = data.reduce((sum, d) => sum + d.signups, 0);

<NumberRoll value={active === null ? total : data[active].signups} />
<AreaChart data={data} xKey="week" series={series} label="Signups per week" onActiveChange={(i) => setActive(i)} />
```

## API reference

### AreaChart

| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `Record<string, string \| number>[]` | | Points, oldest first, evenly spaced. |
| `xKey` | `string` | | The field naming each point, such as an ISO date. Labels with the same key slide when the data changes. |
| `series` | `{ key: string; label: string }` | | The filled series: the field holding its values and its name in the tooltip, legend and hidden table. |
| `compare` | `{ key: string; label: string }` | | A dashed line to read against, such as the previous period. Adds a legend. |
| `label` | `string` | | Names the figure, the slider and the hidden table, e.g. "Signups per week, last 13 weeks". |
| `color` | `string` | `var(--eased-chart-1)` | Any colour token, or `chartColor(n)`. The comparison line uses it too. |
| `curve` | `"monotone" \| "linear"` | `"monotone"` | Monotone never overshoots a point, so a peak in the line is a peak in the data. |
| `height` | `number` | `240` | The plot plus the x labels, in px. The legend sits above it. The width follows the container. |
| `format` | `(value: number) => string` | one decimal | Values in the tooltip and for screen readers. |
| `formatTick` | `(value: number) => string` | compact `format` | Gridline labels, e.g. `1.2K`. |
| `formatX` | `(key: string) => string` | the key | X axis labels. |
| `formatXDetail` | `(key: string) => string` | `formatX` | The tooltip's heading and the screen reader's date, e.g. "Week of Oct 12, 2026". |
| `onActiveChange` | `(index: number \| null, datum: D \| null) => void` | | The scrubbed point, or `null` when scrubbing stops. |
| `className` | `string` | `""` | Appended to the `<figure>`'s classes. |

The active dot is ringed in `var(--eased-surface)`. On another background, set `--chart-surface` on a parent to that colour.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Focuses the plot and selects the newest point. |
| `←` / `→` | Step one point back or forward (`↓` / `↑` too). |
| `PageDown` / `PageUp` | Jump a tenth of the points. |
| `Home` / `End` | The first and the newest point. |
| `Escape` | Lets go of the point. |

## Accessibility

- The chart is a `<figure>` named by `label`. The plot is one `role="slider"` with the same name, and its `aria-valuetext` reads the date and every value, such as "Week of Oct 12, 2026: This period 1,276, Previous period 1,102".
- Every point is also in a visually hidden `<table>`, so screen reader users can read the whole dataset in table navigation.
- The SVG and the tooltip are `aria-hidden`; the slider already says what they show.
- Identity never rests on colour: the comparison is dashed and named in a legend.
- Touch scrubs the plot horizontally and still scrolls the page vertically.

## Responsive

The chart fills its container's width and keeps its height. X labels thin to one per 76px, always keeping the newest, and the tooltip flips to the left of the point before it would leave the plot.

## Motion

The area draws in left to right once, the first time it scrolls into view, over 0.9s on the in-out curve, so it reads like a sentence. New data morphs from the shape on screen instead of redrawing: both series are resampled on shared x positions, so 13 points become 52, point for point, while the value scale and its gridlines slide on the same `smooth` spring (no overshoot, because data should never wobble past its value). X labels that stay slide to their new place; the rest fade. Scrubbing glides the crosshair and the dot on the `snappy` spring, and the tooltip follows on `smooth`, a beat behind, so it never jitters on a fast pointer. Reduced motion: no draw-in, no morph and no glide; the area, crosshair and tooltip move straight to their new place.

## Notes for AI

Pick AreaChart for one metric over time, optionally against a previous period. Pass `series` and, for a comparison, `compare`, each as `{ key, label }` naming fields on the same data. Give it a descriptive `label`, since that is what screen readers hear, and `formatXDetail` for a full date in the tooltip. For a headline above the chart, use `onActiveChange` rather than reading pointer events.

Don't remount it to change data (`key={range}`): pass the new `data` and it morphs. Don't wrap it in your own entrance animation, tooltip or `ResizeObserver`; it draws in, scrubs and sizes itself. Don't use it for more than two series or for negative values; reach for Line Chart (Pro).

## Related

- [Sparkline](/components/sparkline): the same line at word size, without axes.
- [Line Chart](/components/line-chart) (Pro): several series, legend toggles that flatten a series, rolling tooltip values, a loading pulse and ranges shared across charts.
- [Bar Chart](/components/bar-chart): grouped or stacked bars per category.

## Example

`components/eased/area-chart/area-chart-demo.tsx`

```tsx
"use client";

import { useMemo, useState } from "react";
import { DemoFrame } from "../lib/demo-frame";
import { NumberRoll } from "../number-roll/number-roll";
import { Segmented } from "../segmented/segmented";
import { TextSwap } from "../text-swap/text-swap";
import { AreaChart } from "./area-chart";

type Range = "3m" | "6m" | "1y";
const RANGES = [
  { value: "3m", label: "3M" },
  { value: "6m", label: "6M" },
  { value: "1y", label: "1Y" },
] as const;
const WEEKS: Record<Range, number> = { "3m": 13, "6m": 26, "1y": 52 };

// Two years of seeded weekly signups: a slow climb, a holiday dip, a launch in late August
// that is still settling, and noise. Each point is the Monday that starts its week.
const LAST_WEEK = new Date(2026, 9, 12);
const pad = (n: number) => String(n).padStart(2, "0");
const SIGNUPS = (() => {
  let s = 7;
  const rand = () => (s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296;
  return Array.from({ length: 104 }, (_, i) => {
    const date = new Date(LAST_WEEK.getFullYear(), LAST_WEEK.getMonth(), LAST_WEEK.getDate() - (103 - i) * 7);
    const holiday = date.getMonth() === 11 && date.getDate() > 18 ? 0.75 : 1;
    const launch = i >= 96 ? 1 + 0.3 * Math.exp(-(i - 96) / 3.5) : 1;
    const value = (880 + i * 7.5 + Math.sin(i / 6) * 60) * holiday * launch * (0.9 + rand() * 0.2);
    return { week: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, value: Math.round(value) };
  });
})();

const count = new Intl.NumberFormat("en-US");
const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" });
const long = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" });
const parse = (k: string) => {
  const [y, m, d] = k.split("-").map(Number);
  return new Date(y, m - 1, d);
};

export function AreaChartDemo() {
  const [range, setRange] = useState<Range>("3m");
  const n = WEEKS[range];
  const data = useMemo(() => {
    const now = SIGNUPS.slice(-n);
    const before = SIGNUPS.slice(-2 * n, -n);
    return now.map((d, i) => ({ week: d.week, signups: d.value, previous: before[i].value }));
  }, [n]);
  const total = data.reduce((sum, d) => sum + d.signups, 0);
  const [active, setActive] = useState<number | null>(null);
  const point = active !== null ? data[active] : null;

  return (
    <DemoFrame title="Signups" actions={<Segmented aria-label="Range" options={RANGES} value={range} onValueChange={setRange} />}>
      <div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
        <div className="flex flex-col gap-1">
          <p className="text-ui-sm text-text-muted">
            <TextSwap>{point ? `Week of ${long.format(parse(point.week))}` : `Last ${n} weeks`}</TextSwap>
          </p>
          <p className="text-ui-lg font-medium text-text tabular-nums">
            <NumberRoll value={point ? point.signups : total} locale="en-US" />
          </p>
        </div>
        <AreaChart
          data={data}
          xKey="week"
          series={{ key: "signups", label: "This period" }}
          compare={{ key: "previous", label: "Previous period" }}
          label={`Signups per week, last ${n} weeks, against the ${n} before`}
          height={224}
          format={(v) => count.format(v)}
          formatX={(k) => short.format(parse(k))}
          formatXDetail={(k) => `Week of ${long.format(parse(k))}`}
          onActiveChange={(i) => setActive(i)}
        />
      </div>
    </DemoFrame>
  );
}
```
