Pho Design System

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+8.2%
12,001
Failed−3.1%
187

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:

BarChart and LineChart also take:

Chart takes parts as children instead, and:

Chart.Bar:

Chart.Line takes the same, and:

BarChart adds:

LineChart adds:

Accessibility

Guidelines