Skip to content

Latency sparkline

The line layer of Latency timeline: per-turn latency in exact pixels, with no axes or label of its own.

Level
Atom
Category
Voice
Adoption
Not used yet
import { LatencySparkline } from "@oration/canon/components/voice/latency-sparkline";
packages/canon/src/components/voice/latency-sparkline.tsx
AgentMedian latencyLast 12 turnsStatus
Payments desk agent720 msOn target
Remittance questions940 msSlow
Vendor onboarding630 msOn target
Invoice disputes830 msSlow
import { StatusLabel } from "@oration/canon/components/status-dot";import { LatencySparkline } from "@oration/canon/components/voice/latency-sparkline";import { formatMs, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";export function Hero() {    const scaleMax = 1380;    const agents = [        {            name: "Payments desk agent",            turns: [640, 710, 690, 760, 720, 680, 910, 740, 700, 730, 690, 720],        },        {            name: "Remittance questions",            turns: [                820, 870, 1240, 910, 880, 1320, 940, 900, 1180, 960, 930, 980,            ],        },        {            name: "Vendor onboarding",            turns: [590, 620, 640, 610, 660, 630, 600, 650, 620, 610, 640, 630],        },        {            name: "Invoice disputes",            turns: [                760, 790, 820, 780, 1010, 840, 800, 830, 790, 1080, 860, 840,            ],        },    ];    return (        <div className="w-full max-w-2xl overflow-x-auto rounded-xl bg-card shadow-border">            <table className="w-full min-w-[34rem] text-13">                <thead>                    <tr className="border-b border-border text-xs text-muted-foreground">                        <th                            scope="col"                            className="px-4 py-2 text-left font-normal"                        >                            Agent                        </th>                        <th                            scope="col"                            className="px-3 py-2 text-right font-normal"                        >                            Median latency                        </th>                        <th                            scope="col"                            className="px-3 py-2 text-left font-normal"                        >                            Last 12 turns                        </th>                        <th                            scope="col"                            className="px-4 py-2 text-left font-normal"                        >                            Status                        </th>                    </tr>                </thead>                <tbody className="divide-y divide-border">                    {agents.map((agent) => {                        const sorted = [...agent.turns].sort((a, b) => a - b);                        const median =                            sorted[Math.floor(sorted.length / 2)] ?? 0;                        const turns: LatencyTurn[] = agent.turns.map(                            (e2eMs, i) => ({                                id: `${agent.name}-${i}`,                                index: i + 1,                                e2eMs,                                ttfbMs: Math.round(e2eMs * 0.8),                                sttMs: 210,                            }),                        );                        const tone =                            median <= 800                                ? "success"                                : median <= 1200                                  ? "warning"                                  : "danger";                        return (                            <tr key={agent.name}>                                <th                                    scope="row"                                    className="px-4 py-2 text-left font-normal text-foreground"                                >                                    {agent.name}                                </th>                                <td className="px-3 py-2 text-right font-medium tabular-nums">                                    {formatMs(median)}                                </td>                                <td className="px-3 py-2">                                    <span                                        role="img"                                        aria-label={`Latency over the last 12 turns, ${formatMs(sorted[0] ?? 0)} to ${formatMs(sorted.at(-1) ?? 0)}`}                                        className="relative block h-6 w-24"                                    >                                        <LatencySparkline                                            turns={turns}                                            width={96}                                            height={24}                                            scaleMax={scaleMax}                                            active={null}                                        />                                    </span>                                </td>                                <td className="px-4 py-2">                                    <StatusLabel tone={tone}>                                        {tone === "success"                                            ? "On target"                                            : tone === "warning"                                              ? "Slow"                                              : "Over budget"}                                    </StatusLabel>                                </td>                            </tr>                        );                    })}                </tbody>            </table>        </div>    );}

Usage#

Latency sparkline draws end-to-end latency per turn as one ink line with a faint area under it, in exactly the pixels you give it. It is the line layer inside Latency timeline's sparkline view, and it can stand alone in a tight space such as a table cell, where a chart with axes won't fit. It has no axes, thresholds, readout or accessible name of its own, which is the thing people get wrong: put the value and a status label beside it, and name the cell.

When to use

  • In a table cell beside a latency figure, to show whether the last turns were steady or spiky: 720 ms and the line.
  • In a dense agent or call list where every row shares one scale, so heights compare across rows.
  • Inside a custom latency panel that draws its own thresholds and readout, as Latency timeline does.

When not to use

  • For per-turn latency with target and budget lines, a hover readout and keyboard inspection. Use Latency timeline
  • For any other trend in a cell or a stat, such as containment or call volume. Use Mini chart
  • For a single latency value with a verdict. Use Status label
  • For a chart with axes, a legend and several series. Use Chart

The Ink Fill Rule

The line is Graphite Ink at 70% over a 6% ink area. Latency is a magnitude, so it is never indigo and never colored by threshold.

The Label-Beside-Color Rule

The sparkline carries no verdict. Put the figure and a status label (On target, Slow, Over budget) beside it so the cell reads without the line.

The Tabular Figures Rule

The latency figure beside the line is in tabular figures, aligned right in its column.

Anatomy#

  1. Area. The region under the line in ink at 6%, down to the bottom edge.
  2. Line. A 1.5px ink line at 70% with round joins and caps, one point per turn spread across the full width.
  3. Last point. A 2.5px dot on the newest turn. It scales in on spring.moderate each time a new turn arrives.
  4. Active guide. A dashed vertical line in ink at 25% at the inspected turn.
  5. Active point. A 3.5px ring on the inspected turn, filled with the background. It replaces the last point while a turn is active.

Examples#

Sizes

Width and height are pixels, and the parent must be a relative box of the same size. 96 by 24 fits a 36px table row.

64 × 20
96 × 24
160 × 32
import { LatencySparkline } from "@oration/canon/components/voice/latency-sparkline";import { type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";export function Sizes() {    const turns: LatencyTurn[] = [        680, 720, 910, 760, 700, 1040, 780, 730, 690, 760,    ].map((e2eMs, i) => ({        id: `t${i}`,        index: i + 1,        e2eMs,        ttfbMs: e2eMs - 90,        sttMs: 200,    }));    const sizes = [        { width: 64, height: 20 },        { width: 96, height: 24 },        { width: 160, height: 32 },    ];    return (        <div className="flex flex-wrap items-end justify-center gap-10">            {sizes.map((size) => (                <div                    key={size.width}                    className="flex flex-col items-center gap-2"                >                    <span                        role="img"                        aria-label="Latency over the last 10 turns, 680 to 1,040 ms"                        className="relative block"                        style={{ width: size.width, height: size.height }}                    >                        <LatencySparkline                            turns={turns}                            width={size.width}                            height={size.height}                            scaleMax={1380}                            active={null}                        />                    </span>                    <span className="text-xs text-muted-foreground tabular-nums">                        {size.width} × {size.height}                    </span>                </div>            ))}        </div>    );}

Inspecting a turn

The sparkline doesn't handle input. Map the pointer to a turn index, pass it as active and give keyboard users a way in, here a transparent range input over the line.

Hover or focus the line to inspect a turn

import { LatencySparkline } from "@oration/canon/components/voice/latency-sparkline";import { formatMs, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";import * as React from "react";export function Inspect() {    const values = [        720, 760, 690, 910, 1240, 820, 760, 700, 1380, 780, 740, 710,    ];    const turns: LatencyTurn[] = values.map((e2eMs, i) => ({        id: `t${i}`,        index: i + 1,        e2eMs,        ttfbMs: e2eMs - 90,        sttMs: 200,    }));    const width = 280;    const [active, setActive] = React.useState<number | null>(null);    const turn = active !== null ? turns[active] : undefined;    const verdict = (ms: number) =>        ms <= 800 ? "On target" : ms <= 1200 ? "Slow" : "Over budget";    return (        <div className="flex flex-col items-center gap-3">            <div                className="relative h-12"                style={{ width }}                onPointerMove={(event) => {                    const x =                        event.clientX -                        event.currentTarget.getBoundingClientRect().left;                    setActive(                        Math.min(                            turns.length - 1,                            Math.max(                                0,                                Math.round((x / width) * (turns.length - 1)),                            ),                        ),                    );                }}                onPointerLeave={() => setActive(null)}            >                <LatencySparkline                    turns={turns}                    width={width}                    height={48}                    scaleMax={1380}                    active={active}                />                <input                    type="range"                    min={0}                    max={turns.length - 1}                    value={active ?? turns.length - 1}                    onChange={(event) => setActive(Number(event.target.value))}                    onFocus={() => setActive(turns.length - 1)}                    onBlur={() => setActive(null)}                    aria-label="Inspect a turn"                    aria-valuetext={                        turn                            ? `Turn ${turn.index}, ${formatMs(turn.e2eMs)}, ${verdict(turn.e2eMs)}`                            : undefined                    }                    className="peer absolute inset-0 h-full w-full cursor-crosshair opacity-0"                />                <span                    aria-hidden="true"                    className="pointer-events-none absolute -inset-1.5 rounded-md peer-focus-visible:ring-3 peer-focus-visible:ring-ring/40"                />            </div>            <p                aria-live="polite"                className="h-5 text-13 text-muted-foreground tabular-nums"            >                {turn                    ? `Turn ${turn.index}, ${formatMs(turn.e2eMs)}, ${verdict(turn.e2eMs)}`                    : "Hover or focus the line to inspect a turn"}            </p>        </div>    );}

As turns arrive

Append turns with stable ids and the newest point scales in on spring.moderate. The rest of the line redraws in place.

690 msTurn 3
import { LatencySparkline } from "@oration/canon/components/voice/latency-sparkline";import { formatMs, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";import * as React from "react";export function Live() {    const values = [        720, 760, 690, 910, 820, 760, 1180, 700, 780, 740, 690, 1020, 760, 710,    ];    const [count, setCount] = React.useState(3);    React.useEffect(() => {        if (count >= values.length) return;        const id = window.setTimeout(() => setCount((n) => n + 1), 1100);        return () => window.clearTimeout(id);    }, [count, values.length]);    const turns: LatencyTurn[] = values.slice(0, count).map((e2eMs, i) => ({        id: `t${i}`,        index: i + 1,        e2eMs,        ttfbMs: e2eMs - 90,        sttMs: 200,    }));    const last = turns.at(-1);    return (        <div className="flex items-center gap-4">            <span                role="img"                aria-label={`Latency over ${turns.length} turns`}                className="relative block h-8 w-40"            >                <LatencySparkline                    turns={turns}                    width={160}                    height={32}                    scaleMax={1380}                    active={null}                />            </span>            <span className="flex flex-col text-xs">                <span className="font-medium text-foreground tabular-nums">                    {last ? formatMs(last.e2eMs) : "None"}                </span>                <span className="text-muted-foreground tabular-nums">                    Turn {turns.length}                </span>            </span>        </div>    );}

Inside Latency timeline

Where the product actually draws it: the line view of Latency timeline, which adds target and budget lines, the summary, a hover readout and arrow-key inspection.

Latency by turn

p50
760 ms
p95
1,410 ms
Last
730 ms
On target
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { LatencyTimeline, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";import * as React from "react";export function InTimeline() {    const [variant, setVariant] = React.useState<"bars" | "sparkline">(        "sparkline",    );    const turns: LatencyTurn[] = [        { stt: 0, llm: 420, tts: 190, other: 60 },        { stt: 210, llm: 380, tts: 150, other: 20 },        { stt: 240, llm: 330, tts: 140, other: 40 },        { stt: 190, llm: 610, tts: 170, other: 380 },        { stt: 220, llm: 360, tts: 160, other: 30 },        { stt: 260, llm: 420, tts: 180, other: 50 },        { stt: 200, llm: 310, tts: 150, other: 40 },        { stt: 230, llm: 900, tts: 190, other: 90 },        { stt: 210, llm: 350, tts: 140, other: 30 },    ].map((s, i): LatencyTurn => {        const e2eMs = s.stt + s.llm + s.tts + s.other;        return {            id: `lat_${i + 1}`,            index: i + 1,            e2eMs,            ttfbMs: e2eMs - Math.round(s.tts * 0.6),            sttMs: s.stt,            llmMs: s.llm,            ttsMs: s.tts,            speaker: "agent",        };    });    return (        <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-4 shadow-border">            <div className="flex items-center justify-between gap-3">                <h3 className="text-13 font-medium text-foreground">                    Latency by turn                </h3>                <SegmentedControl                    label="Chart type"                    value={variant}                    onValueChange={setVariant}                    options={[                        { value: "bars", label: "Bars" },                        { value: "sparkline", label: "Line" },                    ]}                />            </div>            <LatencyTimeline turns={turns} variant={variant} height={112} />        </div>    );}

States#

Rest
Active
One turn
Over the scale
import { LatencySparkline } from "@oration/canon/components/voice/latency-sparkline";import { type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";export function StatesMatrix() {    const make = (values: number[]): LatencyTurn[] =>        values.map((e2eMs, i) => ({            id: `t${i}`,            index: i + 1,            e2eMs,            ttfbMs: e2eMs - 90,            sttMs: 200,        }));    const cells = [        {            label: "Rest",            turns: make([720, 760, 690, 910, 820, 760, 700]),            active: null,        },        {            label: "Active",            turns: make([720, 760, 690, 910, 820, 760, 700]),            active: 3,        },        { label: "One turn", turns: make([760]), active: null },        {            label: "Over the scale",            turns: make([720, 760, 2400, 910, 820, 760, 700]),            active: null,        },    ];    return (        <div className="grid w-full grid-cols-2 gap-6 sm:grid-cols-4">            {cells.map((cell) => (                <div key={cell.label} className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {cell.label}                    </span>                    <span className="relative block h-8 w-32">                        <LatencySparkline                            turns={cell.turns}                            width={128}                            height={32}                            scaleMax={1380}                            active={cell.active}                        />                    </span>                </div>            ))}        </div>    );}
States
StateTreatment
RestLine, area and the last point.
New turnThe line redraws with the new point and the last dot scales in from 0. Keyed by the last turn's id.
ActivePass active (an index into turns) to draw the guide and ring on that turn instead of the last dot.
One turnA single point, centered. There is no line to draw yet.
Over the scaleValues above scaleMax clamp to the top 4px of the box, so a spike never leaves it.
EmptyWith no turns it draws nothing. The parent shows the empty message (Latency shows up after the first turn.).

Behavior#

  • It renders an absolute inset-0 SVG, so the parent must be relative and sized to the same width and height you pass.
  • Width and height are pixels, not a viewBox, so strokes and dots stay round at any size. Measure the cell (Latency timeline uses a ResizeObserver) or give it a fixed size.
  • Points are spread evenly across the width. y is height − min(1, e2eMs / scaleMax) × (height − 4), so 0 ms sits on the bottom edge.
  • Pass the same scaleMax to every row in a column. Latency timeline uses the larger of the budget threshold × 1.15 and the slowest turn.
  • The SVG has pointer-events: none; inspection is the parent's job. Map the pointer's x to an index and pass it as active.
  • Only the last point animates. Everything else redraws instantly.

Do and don't#

Vendor onboarding
Remittance questions
Do. Share one scaleMax across every row, so a slow agent's line sits visibly higher.
Vendor onboarding
Remittance questions
Don't. Scale each row to its own maximum. Every line fills its box and a 1,400 ms agent looks the same as a 700 ms one.
905 msSlow
Do. Put the latency figure and a status label beside the line, and name the cell.
Don't. Put the line alone in a cell. Nobody can tell whether it is good, and screen readers get nothing.

Content#

  • Name the cell with what the line covers and its range: Latency over the last 12 turns, 640 to 910 ms.
  • Write figures as 720 ms, with a space before the unit, as formatMs from Latency timeline does.
  • Use the same verdict words as Latency timeline: On target up to 800 ms, Slow up to 1,200 ms, Over budget above.

Accessibility#

  • The SVG is aria-hidden. Wrap it in an element with role="img" and an aria-label that states the range, or rely on the visible figure beside it.
  • If you make it inspectable, give it a keyboard path and announce the readout. The example lays a transparent range input over the line with aria-valuetext; Latency timeline uses a focusable group, arrow keys and a polite live region.
  • The line is 70% ink on white and on dark, which reads as a graphic. The figure beside it carries the value at text contrast.
  • The last-point entrance uses a transform, which the app's MotionConfig reducedMotion="user" removes under reduced motion.

Design tokens#

Design tokens
TokenUsed for
--foregroundLine at 70%, area at 6%, guide at 25%, active ring stroke
--backgroundFill of the active point
spring.moderateEntrance of the last point

API reference#

LatencySparkline

The line, area and points. Every prop is required. LatencyTurn comes from @oration/canon/components/voice/latency-timeline.

Other props spread onto Nothing.

Props of LatencySparkline
PropTypeDefaultDescription
turnsRequiredLatencyTurn[]No defaultTurns in order. Only id and e2eMs are read.
widthRequirednumberNo defaultWidth in px. Match the parent box.
heightRequirednumberNo defaultHeight in px. Match the parent box.
scaleMaxRequirednumberNo defaultThe ms value at the top of the box. Share it across rows.
activeRequirednumber | nullNo defaultIndex of the inspected turn, or null to show the last point.

LatencyTurn

Type, exported from latency-timeline.

Props of LatencyTurn
PropTypeDefaultDescription
idRequiredstringNo defaultStable id; keys the last-point entrance.
indexRequirednumberNo default1-based turn number.
e2eMsRequirednumberNo defaultEnd-to-end latency, the value drawn.
ttfbMsRequirednumberNo defaultTime to first byte.
sttMsRequirednumberNo defaultSpeech to text.
llmMsnumberNo defaultLLM time.
ttsMsnumberNo defaultText to speech.
speaker"agent" | "user"No defaultWhose turn it was.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Nothing in the app uses it on its own. Its only importer is Latency timeline; the product's latency table cells show a figure and a status dot, and trend cells use Mini chart.

It doesn't measure itself: the caller passes pixel width and height and must provide a relative box of the same size.

It has no label or ariaLabel prop and is always aria-hidden, so every caller has to name it.

Target and budget lines belong to Latency timeline. A standalone sparkline has no thresholds, so it needs a status label to carry the verdict.

The registry describes it as a table-cell component, but it is really the line layer of Latency timeline. Until it gains sizing and a label, Mini chart is the safer choice for cells.