Sparkline

A small line of a history, at the height of a line of text, without axes or labels (also called a micro chart). Reach for it beside a value or in a table cell, where the shape of the last hours tells more than the figure alone.

import { Sparkline } from "@umriss-ui/core";

It needs at least two values; with fewer it draws nothing. The line is ink, not meaning: the accent tone is for picking out one line among several, never for a verdict.

Examples

Beside a value

Pass the values in time order as data; the line shows their shape and the figure beside it gives the value.

import { Sparkline, Stack, Text } from "@umriss-ui/core";

export default function BesideAValue() {
  return (
    <Stack direction="row" gap={3} align="center">
      <Text as="span" size="sm">
        Parcels delivered per hour
      </Text>
      <Sparkline data={[42, 58, 71, 66, 80, 74, 88]} />
      <Text as="span" size="sm" mono>
        88
      </Text>
    </Stack>
  );
}

Size and tone

width and height default to a table row; the accent tone picks out the one line among several that matters.

import { Sparkline, Stack } from "@umriss-ui/core";

const WEEK = [18, 22, 21, 30, 27, 35, 33];

export default function SizeAndTone() {
  return (
    <Stack direction="row" gap={5} align="center" wrap>
      <Sparkline data={WEEK} />
      <Sparkline data={WEEK} width={160} height={40} />
      <Sparkline data={WEEK} width={160} height={40} tone="accent" />
    </Stack>
  );
}

Handle too little history

With fewer than two values there is no shape, so it draws nothing; say in words why the space is empty.

import { Sparkline, Stack, Text } from "@umriss-ui/core";

const SERVICES = [
  { name: "Checkout", history: [212, 230, 498, 305, 221] },
  { name: "Payouts (new)", history: [140] },
];

export default function TooLittleHistory() {
  return (
    <Stack gap={2}>
      {SERVICES.map((service) => (
        <Stack key={service.name} direction="row" gap={3} align="center">
          <Text as="span" size="sm" style={{ width: 120 }}>
            {service.name}
          </Text>
          {service.history.length >= 2 ? (
            <Sparkline data={service.history} />
          ) : (
            <Text as="span" size="xs" tone="muted" style={{ width: 96 }}>
              no history yet
            </Text>
          )}
          <Text as="span" size="sm" mono>
            {service.history.at(-1)} ms
          </Text>
        </Stack>
      ))}
    </Stack>
  );
}

Show a history per row

In a table each row gets its line at row height, with the value and the objective in columns of their own.

import { Sparkline, Text } from "@umriss-ui/core";

/* 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 ROWS = SERVICES.map((service) => {
  const p95 = metrics(service.id)
    .slice(-24)
    .map((point) => point.p95);
  return { service, p95, now: p95.at(-1)! };
});

const CELL = { padding: "var(--u-space-1) var(--u-space-3)", borderBottom: "1px solid var(--u-edge-color)" };

export default function InATable() {
  return (
    <div style={{ overflowX: "auto" }}>
      <table style={{ borderCollapse: "collapse", maxWidth: 560, width: "100%" }}>
        <thead>
          <tr>
            {["Service", "p95, last 2 h", "Now", "Objective"].map((head, i) => (
              <th key={head} style={{ ...CELL, textAlign: i >= 2 ? "right" : "left", whiteSpace: "nowrap" }}>
                <Text as="span" size="xs" tone="muted">
                  {head}
                </Text>
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {ROWS.map(({ service, p95, now }) => (
            <tr key={service.id}>
              <td style={CELL}>
                <Text as="span" size="sm">
                  {service.name}
                </Text>
              </td>
              <td style={CELL}>
                <Sparkline data={p95} />
              </td>
              <td style={{ ...CELL, textAlign: "right", whiteSpace: "nowrap" }}>
                <Text as="span" size="sm" mono>
                  {now} ms
                </Text>
              </td>
              <td style={{ ...CELL, textAlign: "right", whiteSpace: "nowrap" }}>
                <Text as="span" size="sm" mono tone="muted">
                  {service.latencySlo} ms
                </Text>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

When to use something else

API

SparklineProps

NameTypeDefaultDescription
datarequiredreadonly number[]—Values in chronological order; at least two.

Shown in: Configurator, Beside a value, Size and tone and 4 more

widthnumber | "fill"96Width in pixels; the default is a row width, not a chart width. "fill" takes the width of its container, as a tile's history does; the line keeps its stroke and the end point stays round.

Shown in: Configurator, Size and tone, One above the other (Splitter) and 1 more

heightnumber28Height in pixels; the default is as high as a table row. With width "fill" a stylesheet may set the height instead: the drawing follows its box.

Shown in: Configurator, Size and tone, One above the other (Splitter) and 1 more

tone"ink" | "accent""ink"The line is ink, not meaning: accent only where exactly one line among several is to be emphasised.

Shown in: Configurator, Size and tone

refRef<HTMLSpanElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Also takes every attribute of <span>.

Known limits

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

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index