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.
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>
);
}
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>
);
}
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>
);
}
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>
);
}
Line from @umriss-ui/chartsSparklineProps| Name | Type | Default | Description |
|---|---|---|---|
datarequired | readonly number[] | — | Values in chronological order; at least two. Shown in: Configurator, Beside a value, Size and tone and 4 more |
width | number | "fill" | 96 | Width 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 |
height | number | 28 | Height 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 |
ref | Ref<HTMLSpanElement> | — | Receives the component's root element. from RefPropShown 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>.
What umriss deliberately does not build, and why: ADR-0032.