Charts
Buckets over time, as columns or lines: messages a day, requests an hour,
latency by the minute. Chart is the root: it draws the frame, the scales,
the axes, one tooltip and one legend around the Chart.Bar and
Chart.Line parts it is given, on one set of series colours. BarChart
and LineChart are presets on it for the common one-kind chart, so a page
of all three reads as one system. Built on
@visx/xychart, sized by the parent.
Import
import { Chart } from "@photon-ai/pho-ui/components/chart";
import { BarChart } from "@photon-ai/pho-ui/components/bar-chart";
import { LineChart } from "@photon-ai/pho-ui/components/line-chart";
Data
data is one object per bucket, oldest first and evenly spaced: x is the
bucket’s time (a Date or epoch milliseconds) and the series values sit
beside it under their own keys. series names those keys in drawing order,
with a label for the legend and the tooltip. A bucket may also carry a
breakdown, a list of label and value pairs the tooltip shows under the
series (errors by route, say).
const data = [
{ x: new Date(2026, 8, 1), inbound: 142, outbound: 61 },
{ x: new Date(2026, 8, 2), inbound: 180, outbound: 74 },
];
<BarChart
data={data}
series={[
{ key: "inbound", label: "Received" },
{ key: "outbound", label: "Sent" },
]}
stacked
label="Messages a day, received and sent"
/>;
label is required: it is the figure’s aria-label. The chart writes its
own spoken summary from the series (range, totals or extremes, peaks);
description replaces it.
Composing a chart
Chart takes its series as parts: a Chart.Bar per column series, a
Chart.Line per line, in drawing and legend order. Each names the field it
plots with dataKey and itself with label. Mix them when one figure
should carry two readings of the same quantity, a daily count under its
rolling average, say; two measures of different scale get two charts.
Messages
Messages and 7-day average, Aug 9 to Sep 7. Messages: 5,701 in total, peak 262 at Aug 23. 7-day average: from 159 to 205, peak 205 at Aug 10.
- Messages
- 7-day average
<Chart data={daily} label="Messages a day, with the seven-day average">
<Chart.Bar dataKey="count" label="Messages" />
<Chart.Line dataKey="average" label="7-day average" />
</Chart>
BarChart and LineChart are the same chart with a series array in
place of parts, for the common case of one kind: { key, label, color? }
becomes a part each, and the keys are checked against data.
Bar chart
Columns stand on a zero baseline, never wider than 24px, with the data end
rounded and the baseline end square. stacked builds each column from the
series in order, first at the bottom, with a 2px gap in the surface colour
between segments and corners only on the top one. Hover lifts a bucket and
opens its tooltip.
Messages
Received and Sent, Aug 25 to Sep 7. Received: 2,689 in total, peak 257 at Aug 30. Sent: 1,595 in total, peak 139 at Aug 29.
- Received
- Sent
Without stacked, several series stand side by side in each bucket.
Received and Sent, Aug 25 to Aug 31. Received: 1,310 in total, peak 257 at Aug 30. Sent: 805 in total, peak 139 at Aug 29.
- Received
- Sent
<BarChart
data={messages}
series={[
{ key: "inbound", label: "Received" },
{ key: "outbound", label: "Sent" },
]}
label="Messages a day, received beside sent"
/>
Successful and failed
A series that means good or bad wears the status inks by name: color: "success" and color: "error" are the same greens and reds as a Badge.
When a bucket carries a breakdown, the tooltip lists it under the series
with breakdownTitle over it. The stat row above the plot is a
Stat.Group on variant="plain".
API requests
Successful and Failed, Sep 7, 9:00 PM to Sep 8, 8:00 PM. Successful: 10,356 in total, peak 600 at Sep 8, 7:00 AM. Failed: 179 in total, peak 11 at Sep 8, 5:00 AM.
- Successful
- Failed
const requests = [
{
x: new Date(2026, 8, 7, 21),
successful: 412,
failed: 9,
breakdown: [
{ label: "POST /v1/messages", value: 6 },
{ label: "GET /v1/lines", value: 3 },
],
},
// …
];
<BarChart
data={requests}
series={[
{ key: "successful", label: "Successful", color: "success" },
{ key: "failed", label: "Failed", color: "error" },
]}
stacked
breakdownTitle="Failed, by route"
label="API requests an hour, successful and failed"
/>;
Line chart
One 2px line per series, straight between samples. valueFormat names the
unit on the axis and in the tooltip; baseline="auto" lets the y-axis start
at the data’s low end when the spread is what matters, as with latency.
Hover snaps a crosshair to the nearest bucket and rings every series’ point.
Latency
p50, p90 and p99, Sep 7, 10:15 PM to Sep 7, 10:40 PM. p50: from 118 ms to 140 ms, peak 140 ms at Sep 7, 10:20 PM. p90: from 233 ms to 287 ms, peak 287 ms at Sep 7, 10:34 PM. p99: from 415 ms to 568 ms, peak 568 ms at Sep 7, 10:24 PM.
- p50
- p90
- p99
<LineChart
data={latency}
series={[
{ key: "p50", label: "p50" },
{ key: "p90", label: "p90" },
{ key: "p99", label: "p99" },
]}
valueFormat={(v) => `${v} ms`}
baseline="auto"
label="Request latency, p50, p90 and p99"
/>
One line per destination takes the categorical slots in order, one hue per destination, so a destination keeps its colour when a filter removes its neighbours.
hooks.example.com, api.acme.dev and ops.example.net, Sep 7, 9:00 PM to Sep 8, 8:00 PM. hooks.example.com: from 521 ms to 759 ms, peak 759 ms at Sep 8, 7:00 PM. api.acme.dev: from 388 ms to 497 ms, peak 497 ms at Sep 8, 8:00 AM. ops.example.net: from 926 ms to 1390 ms, peak 1390 ms at Sep 8, 3:00 AM.
- hooks.example.com
- api.acme.dev
- ops.example.net
Dashed bounds
dashed on a series draws it dashed, in the plot and in the legend key. A
min and max around an average share the average’s color, so the three
read as one measurement with its spread.
Max, Average and Min, Sep 7, 9:00 PM to Sep 8, 8:00 PM. Max: from 955 ms to 1415 ms, peak 1415 ms at Sep 8, 7:00 AM. Average: from 624 ms to 757 ms, peak 757 ms at Sep 8, 7:00 AM. Min: from 340 ms to 552 ms, peak 552 ms at Sep 8, 7:00 AM.
- Max
- Average
- Min
<LineChart
data={bounds}
series={[
{ key: "max", label: "Max", color: "chart-1", dashed: true },
{ key: "avg", label: "Average", color: "chart-1" },
{ key: "min", label: "Min", color: "chart-1", dashed: true },
]}
valueFormat={(v) => `${v} ms`}
label="Event destination response time, min, average and max"
/>
Gaps
A bucket with no value (null) breaks a line: a hole reads as a hole. For
a measure that has no reading there rather than a reading of nothing, a
latency percentile in an hour with no requests, gaps="connect" joins the
neighbours straight across it, and emptyBucket is what the tooltip says
on that bucket in place of the rows. A reading with no line to draw — a
hole on each side, or a connected series with one bucket in the window —
is a dot of its own rather than a 2px speck of stroke.
p50, p90 and p99, Sep 7, 10:15 PM to Sep 7, 10:40 PM. p50: from 120 ms to 140 ms, peak 140 ms at Sep 7, 10:20 PM. p90: from 233 ms to 287 ms, peak 287 ms at Sep 7, 10:34 PM. p99: from 415 ms to 568 ms, peak 568 ms at Sep 7, 10:24 PM.
- p50
- p90
- p99
<LineChart
data={sparseLatency}
series={[
{ key: "p50", label: "p50" },
{ key: "p90", label: "p90" },
{ key: "p99", label: "p99" },
]}
gaps="connect"
emptyBucket="No requests"
valueFormat={(v) => `${v} ms`}
label="Request latency across a quiet night"
/>
Area and curve
area washes the surface under the first series at 10%, for a lone series
with a shape worth seeing. curve="monotone" smooths the line without
overshooting a sample; leave it straight unless the quantity is smooth by
nature, since a curve invents values between the points. color: "ink" is
the monochrome series, the site’s black and white.
Requests, Sep 7, 9:00 PM to Sep 8, 8:00 PM. Requests: from 309 to 600, peak 600 at Sep 8, 7:00 AM.
<LineChart
data={requests}
series={[{ key: "successful", label: "Requests", color: "ink" }]}
area
curve="monotone"
label="Requests an hour"
/>
Loading and empty
Data arrives the way a page section does, on the entrance spring and fade
Reveal uses. A chart that mounts with data rises from its base; data
that follows the bones slides in from the left, the same reveal turned
along the time axis. A range switch follows the range: a wider span opens
outward from inside the plot, a narrower one closes inward from outside
it, and the same span slides in again, so the view reads as opening or
closing. Reduced motion makes every arrival static.
loading with no buckets yet shows bones in the chart’s shape, the
coordinate system included: hairline rows and a baseline, a label bone at
every row and along the bottom, and a dozen columns on the baseline. The
frame is marked busy. loading over buckets already drawn holds that
render at half opacity instead, so a refetch never flashes bones or jumps
the layout. With no buckets and nothing loading, the frame says empty.
No data to show.
<BarChart
data={messages ?? []}
series={[{ key: "inbound", label: "Received" }]}
loading={isPending}
empty="No messages in this range."
label="Messages a day"
/>
Range picker
A time range is a ToggleGroup of short-label Toggles, not a chart part:
one row above the charts it scopes, every chart re-rendering against the
same slice. Keep it controlled and ignore an empty change, so one range is
always chosen.
Requests
Requests, Sep 1 to Sep 7. Requests: 66,296 in total, peak 12,074 at Sep 4.
const [range, setRange] = useState("7d");
<ToggleGroup
value={[range]}
onValueChange={([next]) => next && setRange(next)}
aria-label="Time range"
>
<Toggle value="24h" size="sm">
24h
</Toggle>
<Toggle value="7d" size="sm">
7d
</Toggle>
<Toggle value="14d" size="sm">
14d
</Toggle>
<Toggle value="30d" size="sm">
30d
</Toggle>
</ToggleGroup>;
Series colours
Six categorical slots, --color-pho-chart-1 to -6, in a fixed order the
charts assign as they go: sky, orange, purple, olive, magenta, indigo. The
order is the point; it was validated as a set in both modes (adjacent pairs
under simulated colour blindness, contrast on the card and page surfaces),
so assign in order and never past six. Fold a long tail into “Other” first.
success, error and ink are named colours for a series that means
something. A page can set a series’ color to any of these names, and
chartColor(color, index) from components/chart gives the CSS value for
a swatch of its own.
Props
Chart, BarChart and LineChart render a <div> and forward its
attributes. Shared:
- data —
ReadonlyArray<T>(required) — buckets, oldest first, evenly spaced; each withxand the series values - label —
string(required) — the figure’saria-label - description —
string— replaces the generated spoken summary - height —
number(default200) — plot plus x-axis band, in px; the legend adds a line below - width —
number— a fixed width instead of measuring the parent, for a server render or a test - valueFormat —
(value: number) => string— the axis and tooltip number format; grouped digits and a compact axis by default - timeFormat —
(date: Date) => string— the tooltip heading; the date alone for daily buckets, date and time below that - locale —
string— for the default formats; the runtime locale otherwise - legend —
boolean— on for two or more series - baseline —
zero·auto(defaultzero) — where the y-axis starts. Count axes (whole-number data) tick on integers only. - breakdownTitle —
ReactNode— a heading over a bucket’sbreakdownin the tooltip - loading —
boolean— hold the last render at half opacity - empty —
ReactNode(defaultNothing here yet.) — what the frame says with no buckets - emptyBucket —
ReactNode— what the tooltip says on a bucket where no series has a value
BarChart and LineChart also take:
- series —
ReadonlyArray<ChartSeries<T>>(required) —{ key, label, color?, dashed? }in drawing order
Chart takes parts as children instead, and:
- stacked —
boolean(defaultfalse) — one column per bucket, bar parts stacked in order
Chart.Bar:
- dataKey —
string(required) — the field of each datum this series plots - label —
string(required) — the name in the legend and the tooltip - color —
ChartColor— the next categorical slot, in part order, by default
Chart.Line takes the same, and:
- dashed —
boolean— a dashed stroke, in the plot and the legend key - curve —
linear·monotone(defaultlinear) - area —
boolean— a 10% wash under the line - gaps —
break·connect(defaultbreak) — break the line at a bucket with no value, or connect across it
BarChart adds:
- stacked —
boolean(defaultfalse) — one column per bucket, series stacked in order
LineChart adds:
- curve —
linear·monotone(defaultlinear) - area —
boolean(defaultfalse) — a 10% wash under the first series - gaps —
break·connect(defaultbreak) — what every line does at a bucket with no value
Accessibility
- Semantics — the figure is
role="img"withlabelas its name and a generated description (which series, over what range, each one’s total or low and high, and its peak) asaria-describedby. The legend is a real list below the plot. - Server render — the frame, the legend and the description render on the server; the marks register with visx once mounted, so the plot fills on hydration.
- Tooltip — presentational and never a gate: every value in it is also in the description, and the page should keep a table or the stat row for reading without a pointer.
- Motion — the frame fades in once on mount; marks rise from their base on a mount with data, slide in after the bones, and open outward or close inward with the range, all on the entrance spring. Reduced motion collapses all of it.
Guidelines
- Time on x, always. These charts bucket time. A comparison across categories is a table or a stat row.
- One axis. Two measures of different scale get two charts, never two y-axes on one.
- Bars start at zero. A line may use
baseline="auto"when the spread is the story; a column never does. - Colour by meaning or by order. Status inks for successful and failed, the slots in order for everything else, never both in one chart.
- The range picker scopes the page. One row above the charts, never a picker per chart.