---
title: Chart
description: Data over the selected time range. Grey carries the data; colour marks state and the recent part of the story.
---

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

const SERIES = [
  { key: "a", label: "Series A", fill: "fill-chart-neutral", swatch: "bg-chart-neutral" },
  { key: "b", label: "Series B", fill: "fill-chart-bar", swatch: "bg-chart-bar" },
  { key: "c", label: "Series C", fill: "fill-chart-warning", swatch: "bg-chart-warning" },
];

const formatTime = (time: number) => new Date(time).toISOString().slice(11, 16);

export default function ChartBasic() {
  return (
    <Preview block={true}>
      <Chart.Legend
        items={SERIES.map((item) => ({ className: item.swatch, key: item.key, label: item.label }))}
      />
      <Chart.Root
        aria-label="Stacked columns"
        data={DATA}
        formatTime={formatTime}
        keys={SERIES.map((item) => item.key)}
        stacked={true}
      >
        <Chart.Columns series={SERIES.map((item) => ({ className: item.fill, key: item.key }))} />
        <Chart.Tooltip
          formatTime={formatTime}
          series={SERIES.map((item) => ({
            className: item.swatch,
            key: item.key,
            label: item.label,
          }))}
        />
      </Chart.Root>
    </Preview>
  );
}
```

- Use stacked columns (`stacked`) for part-to-whole composition: each segment is a state's share and the full column is the bucket's total. Series stack in the order you pass them, the first at the bottom, so list the worst state last to put it on top. The segments of a column touch, and only the top one is rounded.
- `fill` stretches the plot over the free height of a flex column, such as a card that grows with its row; `height` is then the minimum.
- One y-axis. `yAxis={false}` hides it only for sparkline-sized charts in lists. Charts on an entity page share one height and keep the axis.
- Gridlines follow the scale and domain, or `yTicks`. The time axis shows up to five labels, and only when `Root` has `formatTime`.
- A legend only for two or more series. Legend and tooltip text is never in the series colour. `chartSwatchClassNames` holds the swatch class of each `Line` tone, `previous` as a dashed mark, for legend and tooltip series.
- Put the legend in the `actions` of a `Metric.Card`, or in `Block.Actions` before controls such as the time range. Give each item the summary for the period in `value`, so the numbers sit next to the chart instead of elsewhere on the page.
- One tooltip lists every series at the hovered time. A tooltip series doesn't need a mark on the plot: list related values of the same bucket, such as the parts behind a score, and give a series its own `formatValue` when its unit differs from the chart's.
- A `null` value is a bucket without data, such as a percentile or an average with no requests behind it. Lines and areas bridge it to the next value, a lone value draws as a dot, and the tooltip shows a dash. Counts stay 0.
- Pass `formatTime` and `formatValue` built from the active locale. Without `formatValue`, the axis uses compact numbers and the tooltip full numbers in the active locale.
- The chart renders on the server: columns, lines, thresholds, markers and ranges are laid out relative to the plot, so they need no measured width. Only the tooltip waits for the browser.
- Focus on the chart selects the first bucket. ← and → move between buckets, Home and End jump to the first and last. Enter or Space selects a bucket when `Chart.Tooltip` has `onSelect`; a click does the same.

## Grouped columns

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

export default function ChartGroupedColumns() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Grouped columns" data={DATA} keys={["a", "b", "c"]}>
        <Chart.Columns
          series={[
            { className: "fill-chart-neutral", key: "a" },
            { className: "fill-chart-bar", key: "b" },
            { className: "fill-chart-warning", key: "c" },
          ]}
        />
      </Chart.Root>
    </Preview>
  );
}
```

Use grouped columns, the default (`stacked={false}`), to compare series within a bucket: every series starts from the same baseline. A series with no value above 0 in a bucket takes no slot, so the remaining columns centre in the bucket. The automatic y-domain uses the largest single value across `keys`, not the sum of each bucket. Columns have a maximum width, so few buckets don't turn into wide blocks. Set `partial` on `Columns` when the last bucket is still running: it fades the last column, so its lower count doesn't read as a drop.

## Lines

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

export default function ChartLines() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Lines" data={DATA} keys={["a", "b", "c"]}>
        <Chart.Line dataKey="a" tone="trend" />
        <Chart.Line dataKey="c" tone="muted" />
        <Chart.Line dataKey="b" tail={0.2} />
        <Chart.Tooltip
          formatTime={(time) => new Date(time).toISOString().slice(11, 16)}
          series={[
            { className: "bg-chart-line-muted", dot: true, key: "c", label: "Series C" },
            { className: "bg-chart-line", dot: true, key: "b", label: "Series B" },
            { className: "bg-chart-trend", dot: true, key: "a", label: "Series A" },
          ]}
        />
      </Chart.Root>
    </Preview>
  );
}
```

Use lines for continuous metrics over time, such as latency percentiles. Lines and areas bend smoothly through every point with a monotone curve, which never overshoots: peaks, dips and zero stay where the data puts them. Set `curve="linear"` on `Root` for straight segments. `scale="log"` keeps series with very different ranges readable together. The main series uses the default `tone="story"` with `tail={0.2}`, which draws the end of the line in the accent colour; the others are `muted` and `trend`. Set `dot` on a tooltip series to mark its value at the hovered time.

## Stacked areas

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

const SERIES = [
  { key: "a", label: "Series A", className: "series-1" },
  { key: "b", label: "Series B", className: "series-2" },
  { key: "c", label: "Series C", className: "series-3" },
] as const;

export default function ChartStackedArea() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Stacked areas" data={DATA} keys={["a", "b", "c"]} stacked={true}>
        <Chart.StackedArea
          series={SERIES.map((item) => ({
            key: item.key,
            className: `fill-chart-${item.className}`,
          }))}
        />
        <Chart.Tooltip
          formatTime={(time) => new Date(time).toISOString().slice(11, 16)}
          series={SERIES.map((item) => ({
            key: item.key,
            label: item.label,
            className: `bg-chart-${item.className}`,
          }))}
        />
      </Chart.Root>
    </Preview>
  );
}
```

Use stacked areas for the parts of a whole over time, such as the share each web vital takes of a score. `Chart.StackedArea` draws its series bottom to top, each band on the sum of the ones below it; set `stacked` on `Root` so the axis fits the total. Fill the bands with the `chart-series` colours in order: they stay apart in every palette and theme. A tooltip series can add a `note` before its value, built from the hovered bucket, such as the raw value behind a part of a score.

## Comparison

```tsx
import { Chart, chartSwatchClassNames } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

const CHANGE_AT = DATA[15]?.time ?? 0;

const COMPARED = DATA.map((datum) => ({
  time: datum.time,
  current: datum.b + (datum.time >= CHANGE_AT ? 24 : 0),
  previous: datum.b,
}));

export default function ChartComparison() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Comparison" data={COMPARED} keys={["current", "previous"]}>
        <Chart.Area dataKey="current" tone="warning" />
        <Chart.Line dataKey="previous" tone="previous" />
        <Chart.Line dataKey="current" tone="warning" />
        <Chart.Marker label="15:00" time={CHANGE_AT} tone="warning" />
        <Chart.Tooltip
          formatTime={(time) => new Date(time).toISOString().slice(11, 16)}
          series={[
            { className: chartSwatchClassNames.warning, key: "current", label: "Today" },
            { className: chartSwatchClassNames.previous, key: "previous", label: "Yesterday" },
          ]}
        />
      </Chart.Root>
    </Preview>
  );
}
```

Compare a period with the one before it: draw the previous period as a `previous` line, a thin dashed one, and the current period on top in its health tone, `story`, `warning` or `danger`. `Chart.Area` fills under a series with a light wash of the same tone; put it before the lines so they stay on top. Give the marker the same `tone` when it marks where the change began.

## Thresholds

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

export default function ChartThresholds() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Thresholds" data={DATA} keys={["b"]} yDomain={[0, 120]}>
        <Chart.Threshold label="Warning" value={48} />
        <Chart.Threshold label="Danger" tone="danger" value={96} />
        <Chart.Line dataKey="b" />
      </Chart.Root>
    </Preview>
  );
}
```

Use thresholds for performance budgets or SLO-style limits that readers compare with the metric. This example sets `yDomain={[0, 120]}` so both limits stay visible. A threshold is a thin line tinted with its `tone`, `warning` or `danger`, with the label above it on the right.

## Markers

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

export default function ChartMarkers() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Markers" className="pt-4.5" data={DATA} keys={["a"]}>
        <Chart.Columns series={[{ className: "fill-chart-bar", key: "a" }]} />
        <Chart.Marker label="Label" time={Date.parse("2026-01-01T16:00Z")} />
      </Chart.Root>
    </Preview>
  );
}
```

Use markers for events that explain a change in the data, such as a release or a deploy. A marker is a thin line at its `time`, subtle by default or tinted with `tone`, where each bucket starts at its own time, with a label above the plot; add top padding to `Root` to make room for the label. Set `align="center"` to put the line in the middle of the bucket that holds the time instead. A marker outside the data renders nothing. Label only the marker the reader needs; extra markers stay as lines. Shade the columns after the marker with `getClassName` to separate before from after.

## Ranges

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { DATA } from "./data";

export default function ChartRanges() {
  return (
    <Preview block={true}>
      <Chart.Root aria-label="Ranges" data={DATA} keys={["a"]}>
        <Chart.Columns series={[{ className: "fill-chart-bar", key: "a" }]} />
        <Chart.Range
          className="bg-chart-ramp-1"
          from={Date.parse("2026-01-01T00:00Z")}
          label="Range 1"
          to={Date.parse("2026-01-01T08:00Z")}
        />
        <Chart.Range
          className="bg-chart-ramp-3"
          from={Date.parse("2026-01-01T08:00Z")}
          label="Range 2"
          to={Date.parse("2026-01-01T16:00Z")}
        />
        <Chart.Range
          className="bg-chart-ramp-5"
          from={Date.parse("2026-01-01T16:00Z")}
          label="Range 3"
        />
      </Chart.Root>
    </Preview>
  );
}
```

Use ranges for periods that split the time axis, such as the release each event ran on. A range is a thin rounded strip under the plot that covers whole columns: from the column holding `from` to the column before the one holding `to`, or to the last column without `to`. A range that would end in the column it starts in renders nothing, so merge short periods before drawing them. Colour ranges with the `chart-ramp` steps from light on the left to dark on the right; spread the steps when there are fewer ranges than steps. `label` opens in a tooltip under the strip. Its hover area is taller than the strip, so widen the gap on `Root` to keep it clear of the time labels.

## Loading

```tsx
import { Chart } from "@openperf/deft";

import { Preview } from "../../src/Preview";

export default function ChartLoading() {
  return (
    <Preview block={true}>
      <Chart.Skeleton />
    </Preview>
  );
}
```

Use `Chart.Skeleton` while the data of a chart loads. Give it the same `height`, `yAxis` and `className` as the `Root` it stands in for, and `timeLabels={false}` when the chart has no `formatTime`, so the block keeps its size when the data arrives.

## API

| Prop | Type | Default | Description |
| - | - | - | - |
| `data` | `{ time: number; [key: string]: number \| null }[]` | - | On Root. |
| `curve?` | `"monotone" \| "linear"` | `"monotone"` | On Root. How Line and Area connect points. |
| `keys` | `string[]` | - | On Root. Series that set the y domain. |
| `scale?` | `"linear" \| "log"` | `"linear"` | On Root. |
| `stacked?` | `boolean` | `false` | On Root. Stacked or grouped columns, with a matching y domain. |
| `height?` | `number` | `140` | On Root and Skeleton. |
| `yDomain?` | `[number, number]` | - | On Root. |
| `yTicks?` | `number[]` | - | On Root. Gridline values instead of automatic ticks. |
| `fill?` | `boolean` | `false` | On Root. Grows the plot to the free height; `height` becomes the minimum. |
| `yAxis?` | `boolean` | `true` | On Root and Skeleton. |
| `timeLabels?` | `boolean` | `true` | On Skeleton. |
| `formatTime?` | `(time: number) => ReactNode` | - | On Root for axis labels; required on Tooltip. |
| `formatValue?` | `(value: number) => ReactNode` | - | On Root and Tooltip. |
| `series` | `{ key: string; className: string; label: ReactNode; dot?: boolean; formatValue?: (value: number) => ReactNode; note?: (datum: ChartDatum) => ReactNode }[]` | - | On Columns (only key and className) and Tooltip. |
| `partial?` | `boolean` | `false` | On Columns. Fades the last column: its bucket holds only part of its interval. |
| `getClassName?` | `(index: number, key: string) => string \| undefined` | - | On Columns. Overrides a segment's class, such as shading after a marker. |
| `dataKey` | `string` | - | On Line and Area. |
| `tone?` | `"story" \| "muted" \| "trend" \| "previous" \| "warning" \| "danger"` | `"story"` | On Line. On Area: "story" \| "warning" \| "danger". On Marker: "story" \| "warning" \| "danger". On Threshold: "warning" \| "danger", default "warning". |
| `tail?` | `number` | `0` | On Line. Share of the line, from the end, drawn in the accent colour. |
| `value` | `number` | - | On Threshold. |
| `onSelect?` | `(index: number) => void` | - | On Tooltip. Selects a bucket on click, Enter or Space. |
| `items` | `{ key: string; className: string; label: ReactNode; value?: ReactNode }[]` | - | On Legend. |
| `time` | `number` | - | On Marker. Epoch milliseconds. |
| `label?` | `ReactNode` | - | On Marker and Threshold, drawn above the plot or on the right. On Range, a tooltip on hover. |
| `icon?` | `Icon` | - | On Marker. A Tabler icon that leads the label. |
| `align?` | `"start" \| "center"` | `"start"` | On Marker. |
| `from` | `number` | - | On Range. Epoch milliseconds. |
| `to?` | `number` | - | On Range. Epoch milliseconds; the last column when omitted. |
| `className?` | `string` | - | On Range. Fill class, such as `bg-chart-ramp-1`; a muted fill when omitted. |

Parts: `Root`, `Area`, `Columns`, `Legend`, `Line`, `Marker`, `Range`, `Skeleton`, `Threshold`, `Tooltip`. Other props of `Root` go to the native `figure`, of `Legend` to the `ul`, of `Skeleton` to the `div`.
