Skip to content

Inline stat strip

A single line of label and value pairs for headers and summaries.

Status
Beta
Category
Data display
Adoption
Not used yet
import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";
packages/canon/src/components/inline-stat-strip.tsx

Payment runs, September

Every run from Sep 1 to Sep 28, across all 48 active suppliers.

Runs
18
Invoices paid
3,912
Amount paid
$4.82M of $4.90M scheduled
Run success
99.2%
Failed payments
14
import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";export function Hero() {    return (        <div className="flex w-full max-w-2xl flex-col gap-4 text-left">            <div className="flex flex-col gap-1">                <h3 className="text-xl font-semibold tracking-[-0.015em]">                    Payment runs, September                </h3>                <p className="text-sm text-muted-foreground">                    Every run from Sep 1 to Sep 28, across all 48 active                    suppliers.                </p>            </div>            <InlineStatStrip                stats={[                    { label: "Runs", value: "18" },                    { label: "Invoices paid", value: "3,912" },                    {                        label: "Amount paid",                        value: "$4.82M",                        detail: "of $4.90M scheduled",                    },                    { label: "Run success", value: "99.2%" },                    { label: "Failed payments", value: "14" },                ]}            />        </div>    );}

Usage#

Inline stat strip is a single ruled line of label and value pairs: Runs 18, Invoices paid 3,912, Amount paid $4.82M of $4.90M scheduled. It summarizes a report, a record or a page section right under its title, in the page's own flow rather than on a card. It has no deltas, trends or definitions; the moment a figure needs its change against last week, it belongs in a Stat strip. The usual mistake is the opposite shortcut, joining the figures into one dot-separated sentence.

When to use

  • Under a report title, to summarize what the charts below show: Runs, Invoices paid, Amount paid.
  • In a record header, for the few facts people check first: Open invoices, Avg days to pay, Last payment.
  • When a value needs one piece of context, such as of $4.90M scheduled or 71%, through detail.
  • For three to six figures that read left to right and may wrap on narrow screens.

When not to use

  • When figures need a change against a period or a trend line. Use Stat strip
  • For a line of small metadata under a name in a list row. Use Meta line
  • For many attributes of a record that people edit. Use the attribute rail or a table. Use Table
  • For one ratio with a legend. Use Radial chart
  • For a big-number card per figure. Canon refuses hero-metric grids.

The Layout Over Punctuation Rule

Metadata is never joined into dot-separated strings (A · B · C). Each figure gets its own label and value, separated by space.

The Tabular Figures Rule

Values are set in tabular figures so a strip that updates, or two strips stacked on a page, line up.

Anatomy#

Contained
912 of 1,284
Transferred
290
  1. Rules. A 1px Hairline above and below, with 12px of padding inside. They're structural dividers, not a card.
  2. Label. A dt in 12px Slate Meta.
  3. Value. A dd in 14px at weight 500, tabular figures. Can be any node, such as a status label.
  4. Detail. Optional. The same line in weight 400 Slate Meta after a space: of 1,284, 71%, $38,210.00.
  5. Gap. 40px between pairs, 12px between rows when the line wraps. No separators.

Examples#

With detail

detail adds context after the value in Slate Meta, such as a share of the total. It completes the value; it isn't a change.

Calls
1,284
Contained
912 71%
Transferred
290 23%
Abandoned
82 6%
Avg handle time
4:12
import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";export function WithDetail() {    return (        <div className="w-full max-w-2xl">            <InlineStatStrip                stats={[                    { label: "Calls", value: "1,284" },                    { label: "Contained", value: "912", detail: "71%" },                    { label: "Transferred", value: "290", detail: "23%" },                    { label: "Abandoned", value: "82", detail: "6%" },                    { label: "Avg handle time", value: "4:12" },                ]}            />        </div>    );}

In a record header

Under a supplier's name, the facts people check first. A value can be any node, such as a status label for the W-9.

Northwind Freight

Supplier since March 2023, paid by ACH

Open invoices
14 $38,210.00
Avg days to pay
27
Last payment
Sep 25
W-9
On file
import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { StatusLabel } from "@oration/canon/components/status-dot";export function RecordHeader() {    return (        <div className="flex w-full max-w-2xl flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex items-center gap-3">                <MonogramTile name="Northwind Freight" color="teal" size="lg" />                <div className="flex min-w-0 flex-col">                    <h3 className="truncate text-base font-semibold">                        Northwind Freight                    </h3>                    <p className="text-xs text-muted-foreground">                        Supplier since March 2023, paid by ACH                    </p>                </div>            </div>            <InlineStatStrip                stats={[                    {                        label: "Open invoices",                        value: "14",                        detail: "$38,210.00",                    },                    { label: "Avg days to pay", value: "27" },                    { label: "Last payment", value: "Sep 25" },                    {                        label: "W-9",                        value: (                            <StatusLabel tone="success">On file</StatusLabel>                        ),                    },                ]}            />        </div>    );}

Wrapping

In a narrow column the pairs wrap to a second row 12px below, in reading order. It never scrolls sideways.

Tickets
386
First-response SLA
94%
Median resolution
1d 4h
Reopened
12 3%
import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";export function Wrapping() {    return (        <div className="w-full max-w-xs rounded-xl bg-card p-4 text-left shadow-border">            <InlineStatStrip                stats={[                    { label: "Tickets", value: "386" },                    { label: "First-response SLA", value: "94%" },                    { label: "Median resolution", value: "1d 4h" },                    { label: "Reopened", value: "12", detail: "3%" },                ]}            />        </div>    );}

States#

Loading stats
import { Button } from "@oration/canon/components/button";import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";import { Skeleton } from "@oration/canon/components/skeleton";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { cn } from "@oration/canon/lib/utils";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), 1200);        return () => window.clearTimeout(id);    }, [loading]);    return (        <div className="flex w-full max-w-2xl flex-col items-start gap-3">            <SkeletonReveal                loading={loading}                label="Loading stats"                className="w-full"                skeleton={                    <div                        aria-hidden="true"                        className="flex flex-wrap gap-x-10 gap-y-3 border-y border-border py-3"                    >                        {["w-10", "w-16", "w-20", "w-12"].map((width) => (                            <div                                key={width}                                className="flex flex-col gap-1.5 py-0.5"                            >                                <Skeleton className="h-3 w-16" />                                <Skeleton className={cn("h-4", width)} />                            </div>                        ))}                    </div>                }            >                <InlineStatStrip                    stats={[                        { label: "Runs", value: "18" },                        { label: "Invoices paid", value: "3,912" },                        { label: "Amount paid", value: "$4.82M" },                        { label: "Failed payments", value: "—" },                    ]}                />            </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
RestOne line of pairs between two hairlines.
WrappedWhen the pairs don't fit, they wrap to a second row 12px below. Order stays left to right, top to bottom.
LoadingThere's no built-in skeleton. Draw one in the same layout: the two hairlines, a 12px label bone and a 16px value bone per pair.
No dataShow an en dash as the value for a figure that wasn't measured. Keep the label so the line doesn't reflow.

Behavior#

  • Pass stats as an array of { label, value, detail? }. The strip renders them in order; label is also the React key, so labels must be unique.
  • The strip is flex flex-wrap, so it never scrolls horizontally; it wraps.
  • value and detail accept nodes, so a value can be a StatusLabel or a link.
  • It takes no className or other props. Wrap it in an element to position it.

Do and don't#

Runs
18
Invoices
3,912
Paid
$4.82M
Do. Give each figure its own label and value, separated by layout.

18 runs · 3,912 invoices · $4.82M paid

Don't. Join them into 18 runs · 3,912 invoices · $4.82M paid. It reads as one sentence and breaks the Layout Over Punctuation Rule.
Amount paid
$4.82M of $4.90M
Failed
14
Do. Use detail for context that belongs to the value: the total it's part of, or its share.
Amount paid
$4.82M ↑ 6% vs August
Failed
14 ↑ 3
Don't. Put a colored change in detail. Once a figure needs its change, move to a stat strip with a real delta.

Content#

  • Labels are short nouns in sentence case: Runs, Invoices paid, Avg days to pay. No colons.
  • Values carry their unit and are rounded for reading: $4.82M, 27, Sep 25, 1d 4h.
  • Detail is a fragment that completes the value: of $4.90M scheduled, 71%, of 1,284. No parentheses.
  • Keep the same labels in the same order across related reports, so the eye finds them in the same place.

Accessibility#

  • The strip is a description list: each label is a dt, each value and detail a dd, so screen readers pair them.
  • It has no accessible name of its own. The heading above it provides the context; keep the strip right after that heading.
  • Values and details are text, so they read in grayscale. A status value should be a StatusLabel, dot plus text.
  • Nothing in it is interactive unless you pass a link as a value.

Design tokens#

Design tokens
TokenUsed for
--borderThe hairlines above and below
--muted-foregroundLabels and detail
--foregroundValues

API reference#

InlineStatStrip

The ruled line of label and value pairs.

Props of InlineStatStrip
PropTypeDefaultDescription
statsRequiredInlineStat[]No defaultThe pairs, in reading order.

InlineStat

One pair. Exported as a type.

Props of InlineStat
PropTypeDefaultDescription
labelRequiredstringNo defaultWhat the figure is. Also the React key, so keep it unique.
valueRequiredReactNodeNo defaultThe figure, preformatted.
detailReactNodeNo defaultContext after the value in Slate Meta.

Known gaps#

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

InlineStatStrip takes only stats: no className, no aria-label and no data-slot. The rules above and below can't be turned off, so inside a card it draws two extra dividers.

The one import site, reports/report-parts.tsx, re-exports it as StatStrip and its type as Stat, the same names as the stat strip component. Reports code reads as if it used the other component.

components/agents/list/stat-strip.tsx in the app is a hand-rolled copy of the same markup instead of an import.

Stats are keyed by label, so two pairs with the same label collide.