Inline stat strip
A single line of label and value pairs for headers and summaries.
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
The Tabular Figures Rule
Anatomy#
- Contained
- 912 of 1,284
- Transferred
- 290
- Rules. A 1px Hairline above and below, with 12px of padding inside. They're structural dividers, not a card.
- Label. A
dtin 12px Slate Meta. - Value. A
ddin 14px at weight 500, tabular figures. Can be any node, such as a status label. - Detail. Optional. The same line in weight 400 Slate Meta after a space: of 1,284, 71%, $38,210.00.
- 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#
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> );}| State | Treatment |
|---|---|
| Rest | One line of pairs between two hairlines. |
| Wrapped | When the pairs don't fit, they wrap to a second row 12px below. Order stays left to right, top to bottom. |
| Loading | There'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 data | Show an en dash as the value for a figure that wasn't measured. Keep the label so the line doesn't reflow. |
Behavior#
- Pass
statsas an array of{ label, value, detail? }. The strip renders them in order;labelis also the React key, so labels must be unique. - The strip is
flex flex-wrap, so it never scrolls horizontally; it wraps. valueanddetailaccept nodes, so a value can be aStatusLabelor a link.- It takes no
classNameor other props. Wrap it in an element to position it.
Do and don't#
- Runs
- 18
- Invoices
- 3,912
- Paid
- $4.82M
18 runs · 3,912 invoices · $4.82M paid
- Amount paid
- $4.82M of $4.90M
- Failed
- 14
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
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 add, 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#
| Token | Used for |
|---|---|
--border | The hairlines above and below |
--muted-foreground | Labels and detail |
--foreground | Values |
API reference#
InlineStatStrip
The ruled line of label and value pairs.
| Prop | Type | Default | Description |
|---|---|---|---|
statsRequired | InlineStat[] | No default | The pairs, in reading order. |
InlineStat
One pair. Exported as a type.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | What the figure is. Also the React key, so keep it unique. |
valueRequired | ReactNode | No default | The figure, preformatted. |
detail | ReactNode | No default | Context 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.