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 whenRoothasformatTime. - A legend only for two or more series. Legend and tooltip text is never in the series colour.
chartSwatchClassNamesholds the swatch class of eachLinetone,previousas 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 invalue, so the numbers sit next to the chart instead of elsewhere on the page. - One tooltip lists every series at the hovered time.
- A
nullvalue 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
formatTimeandformatValuebuilt from the active locale. WithoutformatValue, 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.TooltiphasonSelect; 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
data{ time: number; [key: string]: number | null }[]
On Root.
{ time: number; [key: string]: number | null }[]curve?"monotone" | "linear"
On Root. How Line and Area connect points.
"monotone" | "linear""monotone"keysstring[]
On Root. Series that set the y domain.
string[]scale?"linear" | "log"
On Root.
"linear" | "log""linear"stacked?boolean
On Root. Stacked or grouped columns, with a matching y domain.
booleanfalseheight?number
On Root and Skeleton.
number140yDomain?[number, number]
On Root.
[number, number]yTicks?number[]
On Root. Gridline values instead of automatic ticks.
number[]yAxis?boolean
On Root and Skeleton.
booleantruetimeLabels?boolean
On Skeleton.
booleantrueformatTime?(time: number) => ReactNode
On Root for axis labels; required on Tooltip.
(time: number) => ReactNodeformatValue?(value: number) => ReactNode
On Root and Tooltip.
(value: number) => ReactNodeseries{ key: string; className: string; label: ReactNode; dot?: boolean }[]
On Columns (only key and className) and Tooltip.
{ 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.
(index: number, key: string) => string | undefineddataKeystring
On Line and Area.
stringtone?"story" | "muted" | "trend" | "previous" | "warning" | "danger"
On Line. On Area: "story" | "warning" | "danger". On Marker: "story" | "warning" | "danger". On Threshold: "warning" | "danger", default "warning".
"story" | "muted" | "trend" | "previous" | "warning" | "danger""story"tail?number
On Line. Share of the line, from the end, drawn in the accent colour.
number0valuenumber
On Threshold.
numberonSelect?(index: number) => void
On Tooltip. Selects a bucket on click, Enter or Space.
(index: number) => voiditems{ key: string; className: string; label: ReactNode; value?: ReactNode }[]
On Legend.
{ key: string; className: string; label: ReactNode; value?: ReactNode }[]timenumber
On Marker. Epoch milliseconds.
numberlabel?ReactNode
On Marker and Threshold, drawn above the plot or on the right. On Range, a tooltip on hover.
ReactNodeicon?Icon
On Marker. A Tabler icon that leads the label.
Iconalign?"start" | "center"
On Marker.
"start" | "center""start"fromnumber
On Range. Epoch milliseconds.
numberto?number
On Range. Epoch milliseconds; the last column when omitted.
numberclassName?string
On Range. Fill class, such as `bg-chart-ramp-1`; a muted fill when omitted.
stringParts: 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.