Stat strip
Metrics as a strip of label, value, delta and trend, never as hero cards.
Supplier line, today
Changes compared with last Monday
- Calls
- 1,284Up 6%
- Containment
- 71%Up 2 pts
- Avg handle time
- 4:12Down 0:18
- Transfers
- 212Up 11%
import { InfoTip } from "@oration/canon/components/info-tip";import { Stat, StatStrip } from "@oration/canon/components/stat";export function Hero() { return ( <div className="flex w-full flex-col gap-2"> <div className="flex items-baseline justify-between px-1"> <p className="text-sm font-semibold">Supplier line, today</p> <p className="text-xs text-muted-foreground"> Changes compared with last Monday </p> </div> <StatStrip aria-label="Supplier line agents, today"> <Stat label="Calls" value="1,284" delta={{ value: "6%", direction: "up", good: true }} trend={[ 982, 1044, 1120, 1010, 1190, 640, 512, 1106, 1180, 1232, 1098, 1260, 702, 1284, ]} /> <Stat label="Containment" value="71%" delta={{ value: "2 pts", direction: "up", good: true }} trend={[ 64, 66, 65, 67, 68, 66, 69, 68, 70, 69, 71, 70, 72, 71, ]} info={ <InfoTip title="Containment" description="The share of calls an agent finished without transferring to a person. Calls the supplier abandons in the first 10 seconds don't count." /> } /> <Stat label="Avg handle time" value="4:12" delta={{ value: "0:18", direction: "down", good: true }} trend={[ 288, 281, 279, 284, 270, 266, 272, 268, 262, 259, 263, 255, 257, 252, ]} /> <Stat label="Transfers" value="212" delta={{ value: "11%", direction: "up", good: false }} trend={[ 168, 172, 181, 176, 190, 102, 88, 185, 194, 201, 188, 206, 110, 212, ]} /> </StatStrip> </div> );}Usage#
Stat strip is how Oration shows a handful of headline metrics: Stat cells set side by side in a StatStrip, each with a label, a tabular value, an optional change and a small trend line, divided by hairlines on one card. It sits at the top of analytics, queue and report pages, where call volume, containment and handle time are read together. The mistake it exists to prevent is the hero-metric grid: a mosaic of cards, each with a huge number, a tiny label and a colored trend chip. Canon refuses that pattern; metrics are a strip, a row, a table or a real chart.
When to use
- For three to six headline metrics at the top of an analytics, queue or report page: Calls, Containment, Avg handle time, Transfers.
- When each figure needs its change against a period and a glance at its trend.
- Inside a card, under its section header, with
framed={false}so the strip shares the card's surface. - When a metric needs its definition one click away, through an
InfoTipininfo.
When not to use
- For label and value pairs with no change or trend, such as a report summary under a title. Use Inline stat strip
- For a big-number card per metric in a grid. Canon refuses hero-metric grids; use this strip instead.
- For a metric over time that people read closely. Draw a real chart. Use Chart
- For one share of a whole with a legend, such as containment by outcome. Use Radial chart
- For a quota or budget against a limit. Use Meter
- For per-row figures in a list. Put them in table columns, right aligned. Use Table
No hero-metric grids
The Tabular Figures Rule
tabular-nums, so a strip that updates doesn't jitter.The Ink Fill Rule
The Label-Beside-Color Rule
Anatomy#
- Failed payments
- 14Up 3
- Runs
- 18
- Strip.
StatStrip: a grid on Card White with 12px corners and the hairline lift, one equal column per stat from 640px and two up below. - Label. 12px Slate Meta on a 16px row. Names what's measured, and truncates if the cell is narrow.
- Info tip. Optional, from
info. Usually anInfoTipthat defines the metric, such as containment or AHT. - Value. 18px at weight 600 with tightened tracking, tabular figures, on a 28px row.
- Delta. Optional. A 12px arrow and amount in Ledger Green, Signal Red or Slate Meta, followed by an optional comparison such as vs last week.
- Trend. Optional. A 64 by 24px Mini chart line in Ink 65, pushed to the right edge. Hidden below 640px.
- Divider. A 1px Hairline between cells: vertical in one row, and a grid of rules when the strip wraps two up on phones.
Examples#
Direction and verdict
direction picks the arrow and good picks the color. Run success up and response time down are both good news; failed payments up is bad; invoices received is neutral; uptime didn't move.
- Run success
- 99.2%Up 0.4 pts
- Supplier response
- 3h 10mDown 22m
- Failed payments
- 14Up 3
- Invoices received
- 2,418Up 9%
- Portal uptime
- 99.98%No change
import { Stat, StatStrip } from "@oration/canon/components/stat";export function Deltas() { return ( <StatStrip aria-label="Payment operations, this week" className="w-full" > <Stat label="Run success" value="99.2%" delta={{ value: "0.4 pts", direction: "up", good: true }} /> <Stat label="Supplier response" value="3h 10m" delta={{ value: "22m", direction: "down", good: true }} /> <Stat label="Failed payments" value="14" delta={{ value: "3", direction: "up", good: false }} /> <Stat label="Invoices received" value="2,418" delta={{ value: "9%", direction: "up" }} /> <Stat label="Portal uptime" value="99.98%" delta={{ value: "0 pts", direction: "flat" }} /> </StatStrip> );}Comparison label
delta.label says what the change is measured against. With a label, leave out the trend in narrow strips so the value keeps its room.
- First-response SLA
- 94%Down 3 pts vs last week
- Tickets created
- 386Up 4% vs last week
- Median resolution
- 1d 4hDown 5h vs last week
import { Stat, StatStrip } from "@oration/canon/components/stat";export function ComparisonLabel() { return ( <StatStrip aria-label="Support queue, this week" className="w-full"> <Stat label="First-response SLA" value="94%" delta={{ value: "3 pts", direction: "down", good: false, label: "vs last week", }} /> <Stat label="Tickets created" value="386" delta={{ value: "4%", direction: "up", label: "vs last week" }} /> <Stat label="Median resolution" value="1d 4h" delta={{ value: "5h", direction: "down", good: true, label: "vs last week", }} /> </StatStrip> );}Formatting and definitions
format turns a numeric value into its display string, and info puts a definition beside the label for terms like service level and AHT.
- Service level
- 94.2%
- Avg handle time
- 4:12
- Calls waiting
- 7
import { InfoTip } from "@oration/canon/components/info-tip";import { Stat, StatStrip } from "@oration/canon/components/stat";export function FormatAndInfo() { return ( <StatStrip aria-label="Contact center, today" className="w-full"> <Stat label="Service level" value={0.942} format={(n) => `${(n * 100).toFixed(1)}%`} info={ <InfoTip title="Service level" description="Share of conversations answered within each queue's target time, weighted by volume." /> } /> <Stat label="Avg handle time" value={252} format={(s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` } info={ <InfoTip title="Average handle time" description="Talk or chat time plus wrap-up, averaged across every conversation a person handled." /> } /> <Stat label="Calls waiting" value={7} format={(n) => n.toLocaleString("en-US")} /> </StatStrip> );}Inside a card
framed={false} drops the strip's own surface so it shares the card's, under a section header and a hairline.
Payment runs, September
- Runs
- 18
- Invoices paid
- 3,912
- Amount paid
- $4.82M
- Run success
- 99.2%Up 0.4 pts
import { Button } from "@oration/canon/components/button";import { Stat, StatStrip } from "@oration/canon/components/stat";import { toast } from "@oration/canon/components/toast";import { ArrowUpRightIcon } from "lucide-react";export function InACard() { return ( <section aria-labelledby="september-runs" className="w-full rounded-xl bg-card shadow-border" > <div className="flex items-baseline justify-between px-4 pt-4 pb-3"> <h3 id="september-runs" className="text-sm font-semibold"> Payment runs, September </h3> <Button type="button" variant="ghost" size="xs" className="text-muted-foreground" onClick={() => toast.add({ title: "Opened September payment runs" }) } > View runs <ArrowUpRightIcon data-icon="inline-end" aria-hidden="true" /> </Button> </div> <StatStrip framed={false} aria-label="Payment runs, September" className="border-t border-border" > <Stat label="Runs" value="18" /> <Stat label="Invoices paid" value="3,912" /> <Stat label="Amount paid" value="$4.82M" /> <Stat label="Run success" value="99.2%" delta={{ value: "0.4 pts", direction: "up", good: true }} /> </StatStrip> </section> );}On its own
Outside a strip, a stat is a bare description list with no padding or dividers, for a pair of figures inside a panel.
Response latency, 7 days
- p50
- 820 msDown 40 ms
- p95
- 1.4 sUp 0.2 s
import { Stat } from "@oration/canon/components/stat";export function Standalone() { return ( <div className="flex w-full max-w-sm flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <p className="text-sm font-semibold">Response latency, 7 days</p> <div className="flex gap-8"> <Stat label="p50" value="820 ms" delta={{ value: "40 ms", direction: "down", good: true }} /> <Stat label="p95" value="1.4 s" delta={{ value: "0.2 s", direction: "up", good: false }} /> </div> </div> );}States#
import { Button } from "@oration/canon/components/button";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { SkeletonStat } from "@oration/canon/components/skeletons";import { Stat, StatStrip } from "@oration/canon/components/stat";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function Loading() { const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!loading) return; const id = window.setTimeout(() => setLoading(false), 1400); return () => window.clearTimeout(id); }, [loading]); return ( <div className="flex w-full flex-col items-start gap-3"> <SkeletonReveal loading={loading} label="Loading stats" className="w-full" skeleton={<SkeletonStat count={4} trend />} > <StatStrip aria-label="Supplier line agents, today"> <Stat label="Calls" value="1,284" delta={{ value: "6%", direction: "up", good: true }} trend={[ 982, 1044, 1120, 1010, 1190, 640, 512, 1106, 1284, ]} /> <Stat label="Containment" value="71%" delta={{ value: "2 pts", direction: "up", good: true }} trend={[64, 66, 65, 67, 68, 66, 69, 70, 71]} /> <Stat label="Avg handle time" value="4:12" delta={{ value: "0:18", direction: "down", good: true }} trend={[288, 281, 279, 284, 270, 266, 262, 259, 252]} /> <Stat label="Transfers" value="—" /> </StatStrip> </SkeletonReveal> <Button type="button" variant="outline" size="sm" disabled={loading} onClick={() => setLoading(true)} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Reload </Button> </div> );}| State | Treatment |
|---|---|
| Rest | Label, value and any delta and trend, 16px by 12px padding per cell. |
| Loading | SkeletonStat with the same count and trend flag. Its rows are the same 16px and 28px tall, so nothing shifts when data arrives. |
| Good change | good: true: the arrow and amount turn Ledger Green, whether the direction is up or down. |
| Bad change | good: false: the arrow and amount turn Signal Red. |
| Neutral change | good left unset: the arrow and amount stay Slate Meta. Use it when more isn't better or worse, such as invoices received. |
| No change | direction: "flat": a dash icon in Slate Meta, spoken as No change. |
| No trend | With fewer than two points, the trend is left out and the value row keeps its height. |
| No data | Show an en dash as the value and leave out the delta. Don't show 0 for a metric that wasn't measured. |
| Phone | Below 640px the strip is two columns with hairlines between rows, and trends are hidden. |
Behavior#
Statreads a context fromStatStrip. Inside a strip it renders adivofdtandddwith cell padding and dividers; on its own it renders its owndlwith no padding.- The strip is
grid-cols-2below 640px and one auto-sized row from 640px (sm:grid-flow-col sm:auto-cols-fr). Every stat gets an equal share, however many there are. formatruns only whenvalueis a number. Strings render as given, which is what most call sites pass.- The delta's color comes from
good, not fromdirection: handle time going down isgood: trueand turns green. - The delta is drawn
aria-hiddenand spoken from a screen-reader sentence: Up 6% vs last Monday, No change vs last week. - The trend is a decorative Mini chart, since the value and delta already say what it shows.
framed={false}drops the card surface so the strip can sit inside a card; addborder-t border-borderwhen it sits under a header.
Do and don't#
- Calls
- 1,284Up 6%
- Containment
- 71%Up 2 pts
- Avg handle time
- 4:12Down 0:18 vs last week
good from what the change means. Handle time going down is good news, so it's green.- Avg handle time
- 4:12Down 0:18 vs last week
- First-response SLA
- 94%Down 3 pts vs last week
- First-response SLA
- 94%Down 3 pts
Content#
- Labels are short nouns in sentence case: Calls, Containment, Avg handle time, First-response SLA. No units in the label when the value carries them.
- Values carry their unit: 71%, 4:12, 820 ms, $4.82M, 3h 10m. Round to what a person compares, not to the database's precision.
- Deltas use the value's unit: points for percentages (2 pts), time for durations (0:18), percent for counts (6%). The arrow gives the sign, so no plus or minus.
- Comparisons start with vs: vs last week, vs last Monday. When every stat shares one, say it once above the strip.
- Define jargon in the
infotip: Containment, AHT, Service level, FCR.
Accessibility#
- The strip is a description list: each label is a
dtand its value and delta are thedd. Give the strip anaria-labelthat says what and when, such as Supplier line agents, today. - The delta is announced as a sentence (Down 0:18 vs last week) instead of an arrow glyph and a bare number.
- The trend line is hidden from assistive tech; the value and delta already carry what it shows.
InfoTipininfois a 16px button named About plus its title, with an extended hit area.- Deltas pair color with an arrow and an amount; the label names the metric, so the strip reads in grayscale.
Design tokens#
| Token | Used for |
|---|---|
--card | Strip background when framed |
shadow-border | The strip's hairline lift |
--border | Dividers between cells |
--radius-xl | 12px strip corners |
--foreground | Value |
--muted-foreground | Label, comparison and neutral or flat deltas |
--success | A good change |
--destructive | A bad change |
ink-65 | The trend line, through Mini chart |
API reference#
Stat
One metric. Set several inside StatStrip; on its own it renders a bare dl.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | What's measured. Also names the trend chart. |
valueRequired | ReactNode | No default | The figure, usually a preformatted string. |
delta | StatDelta | No default | The change against a period. See StatDelta. |
trend | number[] | No default | Points for the sparkline, oldest first. Needs at least two. |
info | ReactNode | No default | Rendered beside the label, usually an InfoTip. |
format | (value: number) => string | No default | Formats a numeric value. Ignored for strings. |
className | string | No default | Merged onto the cell. |
StatDelta
The shape of Stat's delta prop. Exported as a type.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The amount of change without a sign: 6%, 2 pts, 0:18. |
directionRequired | "up" | "down" | "flat" | No default | Picks the arrow or dash. |
good | boolean | No default | Whether this direction is good news. Unset for a neutral change. |
label | string | No default | The comparison, such as vs last week. |
StatStrip
The row of stats, divided by hairlines and two up on phones. data-slot="stat-strip".
Other props spread onto <dl>.
| Prop | Type | Default | Description |
|---|---|---|---|
framed | boolean | true | Sets the strip on a card surface. Turn it off inside a card. |
aria-label | string | No default | What the metrics describe and for when. |
className | string | No default | Merged after the grid classes; call sites use it for a three-up tablet layout. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Whether a change is good news is carried by color alone. The arrow shows direction, not verdict, and the spoken sentence (Up 140 ms vs last week) doesn't say good or bad either.
In a narrow cell the value truncates first: the delta and trend don't shrink, so a four-up strip in a 48rem column can cut 1,284 to 1,2…. Drop the trend or the comparison label before that happens.
The two-up layout keys off the viewport (max-sm:), not the strip's width, so a strip in a narrow rail on a desktop stays one row and truncates.
format is ignored for string values, and nearly every call site preformats, so number formatting is repeated per page instead of living with the stat.