BoxPlot

A distribution per position (a box plot, box-and-whisker): quartiles, median and whiskers, per service, per group or per hour. Reach for it to compare spreads, not single values.

import { useChart } from "@umriss-ui/charts";

Every number is the caller's: median, the two quartiles and where each whisker ends. The library computes none of them and names no quartile method or whisker rule, so a box aggregated in a database draws as well as one computed in the browser.

A box stands on the x axis as a bar does: a group is a category the axis reads from the row, and a time axis carries a box per hour (ADR-0052). A missing median is a gap - no box is drawn there.

Examples

One box per service

Five numbers per row, computed wherever the caller likes. The x axis reads the service's name: each one is a category with a place and a tick of its own.

import { Tooltip, useChart } from "@umriss-ui/charts";

interface ResponseTime {
  service: string;
  low: number;
  q1: number;
  median: number;
  q3: number;
  high: number;
}

/* Milliseconds per request over the last hour, aggregated by the database. */
const RESPONSE_TIMES: ResponseTime[] = [
  { service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195 },
  { service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238 },
  { service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181 },
];

export default function Minimal() {
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(RESPONSE_TIMES);
  return (
    <Chart height={260} ariaLabel="Response time per service over the last hour">
      <XAxis value="service" />
      <YAxis tickFormat={(v) => `${v} ms`} />
      <BoxPlot
        name="Response time"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
      />
      <Tooltip />
    </Chart>
  );
}

A box per hour on a time axis

Checkout's median latency, every five minutes, gathered into one box per hour. The quartiles and the whisker rule are the caller's - here the last value within 1.5 IQR - and the library draws what it is given.

import { Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */

/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();

const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const HOUR = 3_600_000;

interface HourBox {
  hour: number;
  low: number;
  q1: number;
  median: number;
  q3: number;
  high: number;
}

/** The value at share p of the sorted values, between neighbours. */
function quantile(sorted: readonly number[], p: number): number {
  const at = (sorted.length - 1) * p;
  const lo = Math.floor(at);
  return (sorted[lo] as number) + ((sorted[Math.ceil(at)] as number) - (sorted[lo] as number)) * (at - lo);
}

function hourBoxes(): HourBox[] {
  const byHour = new Map<number, number[]>();
  for (const point of metrics("checkout")) {
    const hour = Math.floor(point.t / HOUR) * HOUR;
    byHour.set(hour, [...(byHour.get(hour) ?? []), point.p50]);
  }
  return [...byHour].map(([hour, values]) => {
    const sorted = values.sort((a, b) => a - b);
    const q1 = quantile(sorted, 0.25);
    const q3 = quantile(sorted, 0.75);
    const reach = 1.5 * (q3 - q1);
    const inside = sorted.filter((v) => v >= q1 - reach && v <= q3 + reach);
    return { hour, low: inside[0] as number, q1, median: quantile(sorted, 0.5), q3, high: inside[inside.length - 1] as number };
  });
}

const BOXES = hourBoxes();

export default function OverTime() {
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(BOXES);
  return (
    <Chart height={280} ariaLabel="Checkout median latency per hour today">
      <XAxis value="hour" time />
      <YAxis tickFormat={(v) => `${v} ms`} />
      <BoxPlot
        name="Checkout p50"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
      />
      <Tooltip />
    </Chart>
  );
}

With outliers

outliers returns the values beyond the whiskers, a list per box. They are drawn with their box and read in its tooltip, the first five written out; one more than three IQR beyond its box is a ring.

import { Tooltip, useChart } from "@umriss-ui/charts";

interface ResponseTime {
  service: string;
  low: number;
  q1: number;
  median: number;
  q3: number;
  high: number;
  outliers: number[];
}

/* Milliseconds per request over the last hour. The whiskers end at the last
   value within 1.5 IQR; the database returned everything beyond as outliers. */
const RESPONSE_TIMES: ResponseTime[] = [
  { service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195, outliers: [224, 98] },
  { service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238, outliers: [281, 395] },
  { service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181, outliers: [189, 214, 104] },
  { service: "Images", low: 99, q1: 126, median: 137, q3: 150, high: 186, outliers: [193, 198, 204, 210, 222, 235, 259, 71] },
];

export default function WithOutliers() {
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(RESPONSE_TIMES);
  return (
    <Chart height={300} ariaLabel="Response time per service over the last hour, with outliers">
      <XAxis value="service" />
      <YAxis tickFormat={(v) => `${v} ms`} />
      <BoxPlot
        name="Response time"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
        outliers="outliers"
      />
      <Tooltip />
    </Chart>
  );
}

Mean and notches: do two medians differ?

Where two boxes' notches do not overlap, their medians differ. The bounds are yours - here the usual median ± 1.57 · IQR / √n. mean draws a small ×: beside the median it shows a skew. count is read, not drawn.

import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";

interface Batch {
  supplier: string;
  low: number;
  q1: number;
  median: number;
  q3: number;
  high: number;
  mean: number;
  n: number;
}

/* Breaking strength of tiles, in newtons, per clay supplier. */
const BATCHES: Batch[] = [
  { supplier: "Hollen", low: 1310, q1: 1395, median: 1430, q3: 1468, high: 1540, mean: 1431, n: 120 },
  { supplier: "Marsk", low: 1290, q1: 1380, median: 1418, q3: 1462, high: 1555, mean: 1426, n: 64 },
  { supplier: "Velde", low: 1240, q1: 1322, median: 1352, q3: 1398, high: 1490, mean: 1371, n: 96 },
];

const notch = (d: Batch) => (1.57 * (d.q3 - d.q1)) / Math.sqrt(d.n);

export default function MeanAndNotches() {
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(BATCHES);
  return (
    <Chart height={300} ariaLabel="Breaking strength per clay supplier, with mean and notches">
      <XAxis value="supplier" />
      <YAxis tickFormat={(v) => `${v} N`} />
      <BoxPlot
        name="Breaking strength"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
        mean="mean"
        notchLower={(d) => d.median - notch(d)}
        notchUpper={(d) => d.median + notch(d)}
        count="n"
        format={(v) => `${Math.round(v)} N`}
        boxWidth={0.5}
      />
      <Legend />
      <Tooltip />
      <DataTable />
    </Chart>
  );
}

Before and after, side by side

Box series on one x axis stand beside each other, as bars do. A click in the legend hides one; it leaves the drawing and the y extent.

import { Legend, Tooltip, useChart } from "@umriss-ui/charts";

interface Spread {
  service: string;
  low: number;
  q1: number;
  median: number;
  q3: number;
  high: number;
}

/* Milliseconds per request, the week before and the week after the release. */
const BEFORE: Spread[] = [
  { service: "Checkout", low: 112, q1: 138, median: 149, q3: 161, high: 195 },
  { service: "Billing", low: 85, q1: 121, median: 140, q3: 173, high: 238 },
  { service: "Search", low: 140, q1: 152, median: 158, q3: 165, high: 181 },
  { service: "Images", low: 99, q1: 126, median: 137, q3: 150, high: 186 },
];
const AFTER: Spread[] = [
  { service: "Checkout", low: 118, q1: 135, median: 143, q3: 152, high: 176 },
  { service: "Billing", low: 101, q1: 119, median: 128, q3: 139, high: 164 },
  { service: "Search", low: 142, q1: 151, median: 157, q3: 164, high: 179 },
  { service: "Images", low: 103, q1: 124, median: 135, q3: 146, high: 172 },
];

export default function Grouped() {
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(BEFORE);
  return (
    <Chart height={280} ariaLabel="Response time per service before and after the release">
      <XAxis value="service" />
      <YAxis tickFormat={(v) => `${v} ms`} />
      {[
        { name: "Before", data: BEFORE },
        { name: "After", data: AFTER },
      ].map((one) => (
        <BoxPlot
          key={one.name}
          name={one.name}
          data={one.data}
          median="median"
          lowerQuartile="q1"
          upperQuartile="q3"
          lowerWhisker="low"
          upperWhisker="high"
        />
      ))}
      <Legend />
      <Tooltip />
    </Chart>
  );
}

Against the objective

A LimitLine shows what was promised. Whether a box breaks it is your judgement, not the library's: this week's whiskers reach past the objective in every region, so its series carries tone="alarm".

import { Legend, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

interface Spread {
  region: string;
  low: number;
  q1: number;
  median: number;
  q3: number;
  high: number;
}

/* Checkout's 95th percentile per five minutes, in ms, per region and week. */
const LAST_WEEK: Spread[] = [
  { region: "Europe", low: 182, q1: 204, median: 216, q3: 229, high: 262 },
  { region: "Americas", low: 191, q1: 213, median: 226, q3: 240, high: 276 },
  { region: "Asia", low: 176, q1: 199, median: 211, q3: 224, high: 258 },
];
const THIS_WEEK: Spread[] = [
  { region: "Europe", low: 196, q1: 231, median: 249, q3: 268, high: 318 },
  { region: "Americas", low: 205, q1: 242, median: 262, q3: 283, high: 339 },
  { region: "Asia", low: 190, q1: 226, median: 244, q3: 263, high: 311 },
];

export default function AgainstTheObjective() {
  const { Chart, XAxis, YAxis, BoxPlot } = useChart(LAST_WEEK);
  return (
    <Chart height={280} ariaLabel="Checkout 95th percentile per region and week against its objective">
      <XAxis value="region" />
      <YAxis tickFormat={(v) => `${v} ms`} />
      <BoxPlot
        name="Last week"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
      />
      <BoxPlot
        name="This week"
        data={THIS_WEEK}
        tone="alarm"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
      />
      <LimitLine value={300} severity="alarm" label="Objective" />
      <Legend />
      <Tooltip />
    </Chart>
  );
}

A box per shift, its medians as a line

The tiles measured in each of six shifts, one box each, and a Line through the medians to read the drift. The tooltip reads box and line together; DataTable lists every number as text.

import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the plant world: tile length per shift in millimetres, as the
   quality database sends it - quartiles and median, the whiskers at the last
   length within 1.5 IQR. */
const BOXES = [
  { shift: "Mon early", low: 599.72, q1: 599.855, median: 599.97, q3: 600.1575, high: 600.4 },
  { shift: "Mon late", low: 599.53, q1: 599.875, median: 600.025, q3: 600.15, high: 600.38 },
  { shift: "Tue early", low: 599.6, q1: 599.88, median: 600.015, q3: 600.1925, high: 600.39 },
  { shift: "Tue late", low: 599.59, q1: 599.89, median: 599.975, q3: 600.1475, high: 600.36 },
  { shift: "Wed early", low: 599.61, q1: 599.8175, median: 600.01, q3: 600.1775, high: 600.45 },
  { shift: "Wed late", low: 599.68, q1: 599.8575, median: 600.035, q3: 600.16, high: 600.53 },
];

const mm = (v: number) => `${v.toFixed(2)} mm`;

export default function Detailed() {
  const { Chart, XAxis, YAxis, BoxPlot, Line } = useChart(BOXES);
  return (
    <Chart height={320} ariaLabel="Tile length per shift, with the medians as a line">
      <XAxis value="shift" label="Shift" />
      <YAxis tickFormat={mm} />
      <BoxPlot
        name="Tile length"
        median="median"
        lowerQuartile="q1"
        upperQuartile="q3"
        lowerWhisker="low"
        upperWhisker="high"
        boxWidth={0.5}
      />
      <Line value="median" name="Median" markers="always" />
      <Legend />
      <Tooltip />
      <DataTable />
    </Chart>
  );
}

Keyboard

The keys of Chart apply here.

API

BoxPlotProps<T>

NameTypeDefaultDescription
medianrequiredValue<T>—The line across the box; null/undefined/NaN/±Infinity means a gap - no box is drawn there, whatever the other numbers say.

Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more

lowerQuartilerequiredValue<T>—The box's lower edge.

Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more

upperQuartilerequiredValue<T>—The box's upper edge.

Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more

lowerWhiskerrequiredValue<T>—Where the lower whisker ends - the caller's rule, not the minimum.

Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more

upperWhiskerrequiredValue<T>—Where the upper whisker ends - the caller's rule, not the maximum.

Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more

outliersListValue<T>—The values beyond the whiskers, per box: an array, empty or missing where there are none. Drawn with their box in its colour - one beyond three IQR of the box's own quartiles as a ring -, read in its tooltip and table row, never hit on their own (ADR-0040).

Shown in: With outliers

meanValue<T>—The mean, drawn as a small ×: beside the median it shows a skew.

Shown in: Mean and notches: do two medians differ?

notchLowerValue<T>—The notch's lower bound - usually of a confidence interval of the median. Both bounds or neither: one alone warns in DEV and is not drawn.

Shown in: Mean and notches: do two medians differ?

notchUpperValue<T>—The notch's upper bound; see notchLower.

Shown in: Mean and notches: do two medians differ?

countValue<T>—How many values stand behind the box; read in the tooltip and the table, not drawn.

Shown in: Mean and notches: do two medians differ?

xAxisIdstring"x"Binding to an x axis.
yAxisIdstring"y"Binding to a y axis.
datareadonly T[]—Series-own data; overrides the container data.

Shown in: Before and after, side by side, Against the objective

namestring"Series n", after its place in the chartThe name in legend and tooltip. Without one a warning stands in DEV.

Shown in: One box per service, A box per hour on a time axis, With outliers and 4 more

format(value: number) => stringthe y axis' tickFormat, else the built-in number formatEvery number of the box as the tooltip and the table write it.

Shown in: Mean and notches: do two medians differ?

colorstringthe palette's --uc-series-NAny CSS colour value.
tone| "ok"
| "warning"
| "alarm"
—A role instead of a colour value; the theme resolves it. color beats it. Set by the caller: a box out of its specification is the caller's judgement, not the library's.

Shown in: Against the objective

boxWidthnumber0.8Width as a fraction of the step, as a bar's: boxes and bars on one x axis share this fraction and stand side by side.

Shown in: Mean and notches: do two medians differ?, A box per shift, its medians as a line

Types on this page

Value<T>

A value read from the row (ADR-0048): the name of a number field, compared by its name, or a function, compared by its source text (ValueFunction).

type Value<T> = NumberField<T> | ValueFunction<T>;

ListValue<T>

A list read from the row: the name of a list field or a function.

type ListValue<T> = ListField<T> | ListFunction<T>;

NumberField<T>

The fields of a row whose value is a number, or absent.

type NumberField<T> = NoInfer<
  {
    [K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
  }[keyof T & string]
>;

ValueFunction<T>

A value computed from the row - the function form of Value. Compared by its source text, not its identity - an inline function is new on every render. Known limit: one that reads a changed closure variable under the same text is not run again; pass a new data reference then. A native or bound function is compared by identity.

type ValueFunction<T> = (d: T, index: number) => number | null | undefined;

ListField<T>

The fields of a row whose value is a list of numbers, or absent.

type ListField<T> = NoInfer<
  {
    [K in keyof T & string]-?: T[K] extends readonly number[] | null | undefined ? K : never;
  }[keyof T & string]
>;

ListFunction<T>

A list computed from the row - a box's outliers (ADR-0040), the function form of ListValue. null/undefined or an empty array is none; a value that is not finite is left out.

type ListFunction<T> = (d: T, index: number) => readonly number[] | null | undefined;

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index