Skip to content

Stat strip

Metrics as a strip of label, value, delta and trend, never as hero cards.

Status
Stable
Category
Data display
Adoption
Not used yet
import { Stat } from "@oration/canon/components/stat";
packages/canon/src/components/stat.tsx

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 InfoTip in info.

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

Big number, tiny label, trend chip, repeated in a grid of cards: Canon refuses it. Show metrics as a stat strip, rows, a table or a real chart.

The Tabular Figures Rule

Every number that changes or gets compared is set in tabular figures. Values and deltas in a stat are tabular-nums, so a strip that updates doesn't jitter.

The Ink Fill Rule

Trend lines are Ink 65. Green and red belong to the delta, and only when you've said whether the change is good news.

The Label-Beside-Color Rule

A delta always shows its arrow and its amount, and the label names what's measured, so the number reads without the green or red.

Anatomy#

Failed payments
14Up 3
Runs
18
  1. 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.
  2. Label. 12px Slate Meta on a 16px row. Names what's measured, and truncates if the cell is narrow.
  3. Info tip. Optional, from info. Usually an InfoTip that defines the metric, such as containment or AHT.
  4. Value. 18px at weight 600 with tightened tracking, tabular figures, on a 28px row.
  5. 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.
  6. Trend. Optional. A 64 by 24px Mini chart line in Ink 65, pushed to the right edge. Hidden below 640px.
  7. 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#

Loading stats
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>    );}
States
StateTreatment
RestLabel, value and any delta and trend, 16px by 12px padding per cell.
LoadingSkeletonStat with the same count and trend flag. Its rows are the same 16px and 28px tall, so nothing shifts when data arrives.
Good changegood: true: the arrow and amount turn Ledger Green, whether the direction is up or down.
Bad changegood: false: the arrow and amount turn Signal Red.
Neutral changegood left unset: the arrow and amount stay Slate Meta. Use it when more isn't better or worse, such as invoices received.
No changedirection: "flat": a dash icon in Slate Meta, spoken as No change.
No trendWith fewer than two points, the trend is left out and the value row keeps its height.
No dataShow an en dash as the value and leave out the delta. Don't show 0 for a metric that wasn't measured.
PhoneBelow 640px the strip is two columns with hairlines between rows, and trends are hidden.

Behavior#

  • Stat reads a context from StatStrip. Inside a strip it renders a div of dt and dd with cell padding and dividers; on its own it renders its own dl with no padding.
  • The strip is grid-cols-2 below 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.
  • format runs only when value is a number. Strings render as given, which is what most call sites pass.
  • The delta's color comes from good, not from direction: handle time going down is good: true and turns green.
  • The delta is drawn aria-hidden and 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; add border-t border-border when it sits under a header.

Do and don't#

Calls
1,284Up 6%
Containment
71%Up 2 pts
Do. Set metrics in one strip on one card, divided by hairlines.
Calls1,284+6%
Containment71%+2 pts
Don't. Build a grid of hero cards with a big number, a tiny label and a colored chip. It's the pattern Canon refuses.
Avg handle time
4:12Down 0:18 vs last week
Do. Set 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
Don't. Color by direction. Red for every downward arrow tells the team their faster calls are a problem.
First-response SLA
94%Down 3 pts vs last week
Do. Say what the change is compared with, in the delta label or once above the strip.
First-response SLA
94%Down 3 pts
Don't. Show a bare 3 pts with no comparison anywhere. Down from what?

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 info tip: Containment, AHT, Service level, FCR.

Accessibility#

  • The strip is a description list: each label is a dt and its value and delta are the dd. Give the strip an aria-label that 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.
  • InfoTip in info is 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#

Design tokens
TokenUsed for
--cardStrip background when framed
shadow-borderThe strip's hairline lift
--borderDividers between cells
--radius-xl12px strip corners
--foregroundValue
--muted-foregroundLabel, comparison and neutral or flat deltas
--successA good change
--destructiveA bad change
ink-65The trend line, through Mini chart

API reference#

Stat

One metric. Set several inside StatStrip; on its own it renders a bare dl.

Props of Stat
PropTypeDefaultDescription
labelRequiredstringNo defaultWhat's measured. Also names the trend chart.
valueRequiredReactNodeNo defaultThe figure, usually a preformatted string.
deltaStatDeltaNo defaultThe change against a period. See StatDelta.
trendnumber[]No defaultPoints for the sparkline, oldest first. Needs at least two.
infoReactNodeNo defaultRendered beside the label, usually an InfoTip.
format(value: number) => stringNo defaultFormats a numeric value. Ignored for strings.
classNamestringNo defaultMerged onto the cell.

StatDelta

The shape of Stat's delta prop. Exported as a type.

Props of StatDelta
PropTypeDefaultDescription
valueRequiredstringNo defaultThe amount of change without a sign: 6%, 2 pts, 0:18.
directionRequired"up" | "down" | "flat"No defaultPicks the arrow or dash.
goodbooleanNo defaultWhether this direction is good news. Unset for a neutral change.
labelstringNo defaultThe 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>.

Props of StatStrip
PropTypeDefaultDescription
framedbooleantrueSets the strip on a card surface. Turn it off inside a card.
aria-labelstringNo defaultWhat the metrics describe and for when.
classNamestringNo defaultMerged 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.