Latency sparkline
The line layer of Latency timeline: per-turn latency in exact pixels, with no axes or label of its own.
- Status
- Experimental
- 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| Agent | Median latency | Last 12 turns | Status |
|---|---|---|---|
| Payments desk agent | 720 ms | On target | |
| Remittance questions | 940 ms | Slow | |
| Vendor onboarding | 630 ms | On target | |
| Invoice disputes | 830 ms | Slow |
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 Label-Beside-Color Rule
The Tabular Figures Rule
Anatomy#
- Area. The region under the line in ink at 6%, down to the bottom edge.
- Line. A 1.5px ink line at 70% with round joins and caps, one point per turn spread across the full width.
- Last point. A 2.5px dot on the newest turn. It scales in on
spring.moderateeach time a new turn arrives. - Active guide. A dashed vertical line in ink at 25% at the inspected turn.
- 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.
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.
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
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#
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> );}| State | Treatment |
|---|---|
| Rest | Line, area and the last point. |
| New turn | The line redraws with the new point and the last dot scales in from 0. Keyed by the last turn's id. |
| Active | Pass active (an index into turns) to draw the guide and ring on that turn instead of the last dot. |
| One turn | A single point, centered. There is no line to draw yet. |
| Over the scale | Values above scaleMax clamp to the top 4px of the box, so a spike never leaves it. |
| Empty | With no turns it draws nothing. The parent shows the empty message (Latency shows up after the first turn.). |
Behavior#
- It renders an
absolute inset-0SVG, so the parent must berelativeand sized to the samewidthandheightyou 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
scaleMaxto 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 asactive. - Only the last point animates. Everything else redraws instantly.
Do and don't#
scaleMax across every row, so a slow agent's line sits visibly higher.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
formatMsfrom 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 withrole="img"and anaria-labelthat 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#
| Token | Used for |
|---|---|
--foreground | Line at 70%, area at 6%, guide at 25%, active ring stroke |
--background | Fill of the active point |
spring.moderate | Entrance 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.
| Prop | Type | Default | Description |
|---|---|---|---|
turnsRequired | LatencyTurn[] | No default | Turns in order. Only id and e2eMs are read. |
widthRequired | number | No default | Width in px. Match the parent box. |
heightRequired | number | No default | Height in px. Match the parent box. |
scaleMaxRequired | number | No default | The ms value at the top of the box. Share it across rows. |
activeRequired | number | null | No default | Index of the inspected turn, or null to show the last point. |
LatencyTurn
Type, exported from latency-timeline.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable id; keys the last-point entrance. |
indexRequired | number | No default | 1-based turn number. |
e2eMsRequired | number | No default | End-to-end latency, the value drawn. |
ttfbMsRequired | number | No default | Time to first byte. |
sttMsRequired | number | No default | Speech to text. |
llmMs | number | No default | LLM time. |
ttsMs | number | No default | Text to speech. |
speaker | "agent" | "user" | No default | Whose 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.