Line

Joins the readings of a series into a course (a line chart, a trend). Reach for it for anything measured over time: latency, a balance, hours left.

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

A missing reading breaks the line; nothing is drawn across a gap. With step a value holds until the next one, for things logged only when they change.

Examples

One series

A Line joins the readings of its value in the order of x; the first y axis draws the grid.

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 CHECKOUT = metrics("checkout");

export default function OneSeries() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <Chart height={280} ariaLabel="Checkout's 95th percentile latency today">
      <XAxis value="t" time />
      <YAxis label="ms" />
      <Line value="p95" name="Checkout p95" />
      <Tooltip />
    </Chart>
  );
}

Several series and a gap

Each Line may bring its own data; a null reading breaks the line instead of being bridged, and a lone reading keeps its marker.

import { Legend, 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 CHECKOUT = metrics("checkout");
const BILLING = metrics("billing");
const SIGN_IN = metrics("sign-in");

/* The image service's exporter restarted from 04:25 to 05:45 and sent a
   single reading at 05:05 in between. */
const IMAGES = metrics("images").map((d) => {
  const minutes = new Date(d.t).getHours() * 60 + new Date(d.t).getMinutes();
  const lost = minutes >= 4 * 60 + 25 && minutes <= 5 * 60 + 45 && minutes !== 5 * 60 + 5;
  return lost ? { ...d, p95: null } : d;
});

export default function MultiSeries() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <Chart height={300} ariaLabel="95th percentile latency of four services today">
      <XAxis value="t" time />
      <YAxis label="ms" />
      <Line value="p95" name="Checkout" />
      <Line data={BILLING} value="p95" name="Billing" />
      <Line data={SIGN_IN} value="p95" name="Sign-in" dash={[4, 4]} />
      <Line data={IMAGES} value="p95" name="Image service" />
      <Legend placement="top" />
      <Tooltip mode="x" />
    </Chart>
  );
}

Hold a value until it changes

A value logged only when it changes holds until the next entry; step draws it so, and a gap ends the hold.

import { Legend, 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;
}

interface ReplicaChange {
  t: number;
  /** Checkout's instances from here on; `null` while the autoscaler was paused. */
  replicas: number | null;
}

/** Checkout's instances today, logged only when the autoscaler changes them.
    It was paused for a database upgrade from 03:00 to 04:00; the last entry,
    at now, closes the hold. */
const REPLICAS: readonly ReplicaChange[] = [
  { t: at(17, 0), replicas: 4 },
  { t: at(17, 3), replicas: null },
  { t: at(17, 4), replicas: 3 },
  { t: at(17, 6, 30), replicas: 5 },
  { t: at(17, 8), replicas: 8 },
  { t: at(17, 9, 45), replicas: 12 },
  { t: at(17, 10, 15), replicas: 9 },
  { t: NOW, replicas: 9 },
];

const CHECKOUT = metrics("checkout");

export default function StepLine() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <Chart height={260} ariaLabel="Checkout's requests and the instances serving them today">
      <XAxis value="t" time />
      <YAxis label="Requests/min" />
      <YAxis id="instances" position="right" domain={[0, 15]} label="Instances" />
      <Line value="requests" name="Requests" />
      <Line data={REPLICAS} value="replicas" yAxisId="instances" name="Instances" step strokeWidth={2} />
      <Legend />
      <Tooltip mode="x" />
    </Chart>
  );
}

Compare progress with a plan

A sprint's burn-down: the ideal as a quiet dashed line, the hours left as the course, which stops where the days to come begin.

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

/* Data from the planning world, written out here so the example runs on its own. */
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();

/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
  id: string;
  /** The project. */
  task: string;
  /** The person. */
  lane: string;
  from: number;
  to: number;
  name: string;
  sprint: string;
  /** Hours, as estimated at planning. */
  estimate: number;
  status: "to do" | "in progress" | "in review" | "done";
}

const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
  ({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });

const WORK: readonly WorkItem[] = [
  item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
  item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
  item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
  item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
  item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
  item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
  item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
  item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
  item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
  item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
  item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
  item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
  item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
  item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
  item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
  item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];

interface BurndownPoint {
  /** The working day, at 17:00. */
  t: number;
  /** Where the line would be, burning evenly. */
  ideal: number;
  /** Hours left, `null` for the days still to come. */
  remaining: number | null;
}

/** Sprint 14's ten working days. The team fell behind in the first week, when
    two were away, and is catching up. */
const BURNDOWN: readonly BurndownPoint[] = (() => {
  const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
  const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
  const burnt = [0, 22, 38, 60, 71, 98];
  return days.map((day, i) => ({
    t: at(day, 17),
    ideal: Math.round(total * (1 - i / (days.length - 1))),
    remaining: burnt[i] === undefined ? null : total - burnt[i]!,
  }));
})();

const day = (v: number) => new Date(v).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" });

export default function BurnDown() {
  const { Chart, XAxis, YAxis, Line } = useChart(BURNDOWN);
  return (
    <Chart height={260} ariaLabel="Sprint 14: hours left against the ideal">
      <XAxis value="t" ticks={BURNDOWN.map((d) => d.t)} tickFormat={day} />
      <YAxis label="Hours left" />
      <Line value="ideal" name="Ideal" color="var(--uc-color-text)" dash={[4, 4]} />
      <Line value="remaining" name="Remaining" markers="always" strokeWidth={2} />
      <Legend />
      <Tooltip mode="x" />
    </Chart>
  );
}

When to use something else

Keyboard

The keys of Chart apply here.

API

LineProps<T>

NameTypeDefaultDescription
valuerequiredValue<T>—Y value - a number field of the row or a function of it; null/undefined/NaN/±Infinity means a gap.

Shown in: One series, Several series and a gap, Hold a value until it changes and 47 more

xAxisIdstring"x"Binding to an x axis.

Shown in: Add axes for other magnitudes (Axis), Bind every kind of series to a second axis (Axis)

yAxisIdstring"y"Binding to a y axis.

Shown in: Hold a value until it changes, Add axes for other magnitudes (Axis), Align a second axis to the grid (Axis) and 4 more

datareadonly T[]—Series-own data; overrides the container data.

Shown in: Several series and a gap, Hold a value until it changes, Tell series apart without colour (Chart) and 6 more

namestring"Series n", after its place in the chartThe name in legend and tooltip. Without one a warning stands in DEV - an unnamed series is a colour nobody can look up.

Shown in: One series, Several series and a gap, Hold a value until it changes and 47 more

format(value: number) => stringthe y axis' tickFormat, else the built-in number formatThe value as the tooltip writes it.

Shown in: Write each value in its unit (Tooltip), Show the values as a table (Tooltip), Table a long series (Tooltip) and 1 more

colorstringthe palette's --uc-series-NAny CSS colour value.

Shown in: Compare progress with a plan, Fix the domain and name the ticks (Axis), Watch latency against its objective (Scenarios) and 1 more

tone| "ok"
| "warning"
| "alarm"
—A role instead of a colour value; the theme resolves it.

Shown in: Compare the budget with the forecast (Scenarios)

strokeWidthnumber1.5Line width in CSS pixels.

Shown in: Hold a value until it changes, Compare progress with a plan, Mix series kinds (Chart) and 9 more

dashreadonly number[]a solid lineDash pattern as a run of lengths in CSS pixels.

Shown in: Several series and a gap, Compare progress with a plan, Add axes for other magnitudes (Axis) and 1 more

markers| "auto"
| "always"
| "never"
"auto"When the individual points are drawn as marks: auto every point up to 60 points and above that only a point between two gaps - it has no line to be seen by - always, or never, not even that one.

Shown in: Compare progress with a plan, Draw a series per group of long rows (Chart), Fix the domain and name the ticks (Axis) and 4 more

stepboolean—Sample-and-hold: each value holds as a horizontal until the next sample and jumps there - a target, a digital signal. A gap ends the hold at its x. The tooltip reports the sample the hold began with.

Shown in: Hold a value until it changes

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>;

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;

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