Meta line
A 12px line of metadata whose parts are separated by gaps, never by dots.
- $38,210.00
Northwind Freight
Net 3014 open invoicesLast paid Sep 25 - $12,904.50
Halcyon
Net 456 open invoicesLast paid Sep 18 - $2,980.00
Orchard Street
Net 152 open invoicesLast paid Sep 25
import { Meta } from "@oration/canon/components/meta";import { MonogramTile } from "@oration/canon/components/monogram-tile";export function Hero() { const suppliers = [ { name: "Northwind Freight", color: "teal" as const, terms: "Net 30", open: 14, lastPaid: "Sep 25", balance: "$38,210.00", }, { name: "Halcyon", color: "violet" as const, terms: "Net 45", open: 6, lastPaid: "Sep 18", balance: "$12,904.50", }, { name: "Orchard Street", color: "amber" as const, terms: "Net 15", open: 2, lastPaid: "Sep 25", balance: "$2,980.00", }, ]; return ( <ul className="flex w-full max-w-xl flex-col rounded-xl bg-card p-2 text-left shadow-border"> {suppliers.map((supplier) => ( <li key={supplier.name} className="flex items-center gap-3 rounded-lg px-2 py-2.5" > <MonogramTile name={supplier.name} color={supplier.color} size="lg" /> <div className="min-w-0 flex-1"> <p className="truncate text-13 font-medium"> {supplier.name} </p> <Meta> <span>{supplier.terms}</span> <span className="tabular-nums"> {supplier.open} open invoices </span> <span>Last paid {supplier.lastPaid}</span> </Meta> </div> <span className="text-13 font-medium tabular-nums"> {supplier.balance} </span> </li> ))} </ul> );}Usage#
Meta line is the 12px line of small facts under a name in a list row: Net 30, 14 open invoices, Last paid Sep 25. Its parts are separated by a 12px gap and wrap onto a second line, never joined with dots. It exists so the product never writes Net 30 · 14 open invoices · Last paid Sep 25; each part is its own span and layout does the separating. The other thing to get right is the parts themselves: short noun phrases with their numbers, not bare figures that need a legend.
When to use
- Under a row's name in a list or settings table: a supplier's terms and open invoices, a passkey's added and last-used dates.
- For a webhook or integration's status, event count and delivery rate, with the status as a status label.
- Under a card title for the two or three facts that tell similar items apart.
- Anywhere you're tempted to join facts with a middle dot.
When not to use
- For headline figures under a page or report title. Use Inline stat strip
- For metrics with a change and a trend. Use Stat strip
- For a sentence that describes the item. Write it as a description line in 13px.
- For values people filter or sort by across many rows. Give them table columns. Use Data grid
- For option values such as stage or tier on their own. Use Tag
The Layout Over Punctuation Rule
The Tabular Figures Rule
tabular-nums, so a column of rows lines up. Meta doesn't set it for you.The Label-Beside-Color Rule
Anatomy#
- Line. A wrapping flex row in 12px Slate Meta, centered vertically.
- Status part. Optional, usually first: a status label, dot plus text, at 12px.
- Part. A span holding one fact as a short phrase: 6 events, Last paid Sep 25. It can hold an icon or tags.
- Gap. 12px between parts and 2px between lines when it wraps. The only separator.
Examples#
With a status
A webhook's status leads as a status label, then its event count and delivery rate. A failing rate can take Signal Red because the label beside it already says Failing.
https://erp.cedarline.example/hooks/payments
Posts settled payments to the ERP
https://hooks.halcyon.example/remittance
Sends remittance advice to Halcyon
import { Meta } from "@oration/canon/components/meta";import { StatusLabel } from "@oration/canon/components/status-dot";export function WithStatus() { return ( <div className="flex w-full max-w-xl flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border"> <div className="min-w-0"> <p className="truncate font-mono text-xs"> https://erp.cedarline.example/hooks/payments </p> <p className="mt-0.5 truncate text-13 text-muted-foreground"> Posts settled payments to the ERP </p> <Meta className="mt-1"> <StatusLabel tone="success" className="text-xs"> Delivering </StatusLabel> <span>6 events</span> <span className="tabular-nums"> 99.4% delivered, 1,208 in 7 days </span> </Meta> </div> <div className="min-w-0"> <p className="truncate font-mono text-xs"> https://hooks.halcyon.example/remittance </p> <p className="mt-0.5 truncate text-13 text-muted-foreground"> Sends remittance advice to Halcyon </p> <Meta className="mt-1"> <StatusLabel tone="danger" className="text-xs"> Failing </StatusLabel> <span>2 events</span> <span className="text-destructive tabular-nums"> 61% delivered, 44 in 7 days </span> </Meta> </div> </div> );}Wrapping
In a narrow card, whole parts move to a second line 2px below. Nothing truncates and no separator is left dangling.
Remittance questions
import { Meta } from "@oration/canon/components/meta";export function Wrapping() { return ( <div className="w-full max-w-64 rounded-xl bg-card p-4 text-left shadow-border"> <p className="text-13 font-medium">Remittance questions</p> <Meta className="mt-0.5"> <span>Assigned to Aisha Bello</span> <span className="tabular-nums">9 waiting</span> <span>First response in 12 min</span> </Meta> </div> );}In settings rows
Passkeys in account security: the name, then when it was added and last used. Removing one offers Undo.
MacBook Pro
YubiKey 5C
import { Button } from "@oration/canon/components/button";import { Meta } from "@oration/canon/components/meta";import { toast } from "@oration/canon/components/toast";import { FingerprintIcon, KeyRoundIcon } from "lucide-react";import * as React from "react";export function SettingsRows() { const [passkeys, setPasskeys] = React.useState([ { id: "pk_1", name: "MacBook Pro", icon: FingerprintIcon, added: "Aug 3", lastUsed: "today", }, { id: "pk_2", name: "YubiKey 5C", icon: KeyRoundIcon, added: "Mar 12", lastUsed: "Sep 21", }, ]); const remove = (id: string) => { const before = passkeys; const target = passkeys.find((passkey) => passkey.id === id); setPasskeys((current) => current.filter((passkey) => passkey.id !== id), ); toast.add({ title: `Removed ${target?.name ?? "passkey"}`, actionProps: { children: "Undo", onClick: () => setPasskeys(before), }, }); }; return ( <div className="flex w-full max-w-xl flex-col divide-y divide-border rounded-xl bg-card text-left shadow-border"> {passkeys.map((passkey) => ( <div key={passkey.id} className="flex items-center gap-3 px-4 py-3" > <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> <passkey.icon aria-hidden="true" className="size-4" /> </span> <div className="min-w-0 flex-1"> <p className="truncate text-sm font-medium"> {passkey.name} </p> <Meta> <span>Added {passkey.added}</span> <span>Last used {passkey.lastUsed}</span> </Meta> </div> <Button type="button" variant="ghost" size="sm" onClick={() => remove(passkey.id)} > Remove </Button> </div> ))} {passkeys.length === 0 ? ( <p className="px-4 py-3 text-13 text-muted-foreground"> No passkeys. You sign in with your password and a code. </p> ) : null} </div> );}States#
| State | Treatment |
|---|---|
| One line | Parts sit on one line with 12px gaps. |
| Wrapped | When the row is narrow, whole parts move to the next line 2px below. A part never breaks in the middle unless it's longer than the row. |
| Emphasized part | A part that's a problem can take a semantic text color, such as a failing delivery rate in Signal Red, beside a status label that says so. |
Behavior#
- Meta is a styled
div; every prop spreads onto it. Put each fact in its own child element so the gap can separate them. - Text nodes directly inside Meta become flex items too, so
Net 30 · 14 invoicesas one string is still one part. Split it. - It wraps instead of truncating. If a row must stay one line, truncate the longest part yourself with
truncateand atitle. - Parts can be interactive, such as a link to the queue, but the line itself is not.
Do and don't#
Northwind Freight
Northwind Freight
Net 30 · 14 open invoices · Last paid Sep 25
Halcyon
Halcyon
ERP payments hook
ERP payments hook
Content#
- Two to four parts. More than that belongs in columns or a detail view.
- Each part is a short phrase, sentence case, no trailing period: Net 30, Added Aug 3, Last used today.
- Numbers carry their noun and are tabular: 14 open invoices, 99.4% delivered.
- Dates are short: Sep 25, today, Mar 12. Keep the same form across rows.
- Commas and words are fine inside a part: 99.4% delivered, 1,208 in 7 days, Assigned to Aisha Bello.
- Put the most distinguishing fact first, and a status first of all.
Accessibility#
- Parts are separated only visually. A screen reader reads the line as one run of words, so write parts that still make sense back to back: Net 30, 14 open invoices, Last paid Sep 25 reads fine; 6 27d 9/18 doesn't.
- Where two parts could run together, such as two numbers, add a visually hidden comma or write the noun into each part.
- Icons inside parts are decorative and
aria-hidden="true"; the text carries the fact. - Keep the line in Slate Meta. Don't lighten it to Faint Slate or with opacity; 12px text needs the contrast.
- A status part is a status label, so the state is read aloud as a word, not a color.
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Text color of the line |
text-xs | 12px size with a 16px line height |
API reference#
Meta
The meta line: flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | No default | One element per fact. |
className | string | No default | Merged after the defaults, such as mt-1. Use text-[13px] rather than text-13 to change the size; the merge drops text-13. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Only the settings kit imports Meta, and re-exports it to settings pages. Other lists hand-roll the same flex gap-x-3 text-xs text-muted-foreground line.
The app still has about 20 dot-joined strings that Meta should replace, in the contact center (callback rows, team cells, the monitor sheet, swap requests) and ticketing (view rows, report insights).
Meta has no data-slot and doesn't set tabular-nums, so numbers in it only align if each call site adds it.
Parts are separated for the eye only; there's no built-in separator for screen readers.