Skip to content
OpenPerf
Esc
↑↓navigate↵open⌘Jpreview
On this page

Chart

Data over the selected time range. Grey carries the data; colour marks state and the recent part of the story.

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. Only the top segment is rounded.
  • 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 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 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

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.

Lines

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.

Comparison

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

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

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

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

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

PropType
data{ time: number; [key: string]: number | null }[]

On Root.

Type{ time: number; [key: string]: number | null }[]
curve?"monotone" | "linear"

On Root. How Line and Area connect points.

Type"monotone" | "linear"
Default"monotone"
keysstring[]

On Root. Series that set the y domain.

Typestring[]
scale?"linear" | "log"

On Root.

Type"linear" | "log"
Default"linear"
stacked?boolean

On Root. Stacked or grouped columns, with a matching y domain.

Typeboolean
Defaultfalse
height?number

On Root and Skeleton.

Typenumber
Default140
yDomain?[number, number]

On Root.

Type[number, number]
yTicks?number[]

On Root. Gridline values instead of automatic ticks.

Typenumber[]
yAxis?boolean

On Root and Skeleton.

Typeboolean
Defaulttrue
timeLabels?boolean

On Skeleton.

Typeboolean
Defaulttrue
formatTime?(time: number) => ReactNode

On Root for axis labels; required on Tooltip.

Type(time: number) => ReactNode
formatValue?(value: number) => ReactNode

On Root and Tooltip.

Type(value: number) => ReactNode
series{ key: string; className: string; label: ReactNode; dot?: boolean }[]

On Columns (only key and className) and Tooltip.

Type{ key: string; className: string; label: ReactNode; dot?: boolean }[]
getClassName?(index: number, key: string) => string | undefined

On Columns. Overrides a segment's class, such as shading after a marker.

Type(index: number, key: string) => string | undefined
dataKeystring

On Line and Area.

Typestring
tone?"story" | "muted" | "trend" | "previous" | "warning" | "danger"

On Line. On Area: "story" | "warning" | "danger". On Marker: "story" | "warning" | "danger". On Threshold: "warning" | "danger", default "warning".

Type"story" | "muted" | "trend" | "previous" | "warning" | "danger"
Default"story"
tail?number

On Line. Share of the line, from the end, drawn in the accent colour.

Typenumber
Default0
valuenumber

On Threshold.

Typenumber
onSelect?(index: number) => void

On Tooltip. Selects a bucket on click, Enter or Space.

Type(index: number) => void
items{ key: string; className: string; label: ReactNode; value?: ReactNode }[]

On Legend.

Type{ key: string; className: string; label: ReactNode; value?: ReactNode }[]
timenumber

On Marker. Epoch milliseconds.

Typenumber
label?ReactNode

On Marker and Threshold, drawn above the plot or on the right. On Range, a tooltip on hover.

TypeReactNode
icon?Icon

On Marker. A Tabler icon that leads the label.

TypeIcon
align?"start" | "center"

On Marker.

Type"start" | "center"
Default"start"
fromnumber

On Range. Epoch milliseconds.

Typenumber
to?number

On Range. Epoch milliseconds; the last column when omitted.

Typenumber
className?string

On Range. Fill class, such as `bg-chart-ramp-1`; a muted fill when omitted.

Typestring

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.

Was this page helpful?