Health bar
A row of verdict ticks, one per period, with the counts written beside it.
Supplier portal uptime, 30 days
99.88%28 operational, 1 degraded, 1 outage
import { HealthBar, type HealthPoint } from "@oration/canon/components/health-bar";export function Hero() { const uptime = [ 100, 100, 99.98, 100, 100, 100, 99.2, 100, 100, 100, 99.99, 100, 100, 100, 100, 97.4, 99.9, 100, 100, 100, 100, 100, 99.97, 100, 100, 100, 100, 100, 99.99, 100, ]; const points: HealthPoint[] = uptime.map((value, index) => ({ label: index < 2 ? `Aug ${30 + index}` : `Sep ${index - 1}`, value, status: value >= 99.9 ? "good" : value >= 99 ? "fair" : "poor", })); return ( <section aria-labelledby="portal-uptime" className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border" > <div className="flex items-baseline justify-between gap-4"> <h3 id="portal-uptime" className="text-sm font-semibold"> Supplier portal uptime, 30 days </h3> <span className="text-13 font-medium tabular-nums">99.88%</span> </div> <HealthBar points={points} ariaLabel="Supplier portal uptime per day, Aug 30 to Sep 28" statusLabels={{ good: "Operational", fair: "Degraded", poor: "Outage", }} formatValue={(value) => `${value}% uptime`} /> </section> );}Usage#
Health bar is a row of thin ticks, one per period, each colored by its verdict: good, fair or poor. Under it, the counts are written out (27 good, 2 fair, 1 poor), so the status never rides on color alone. Hover or focus a tick for its date, value and the trend around it. It answers "how has this been lately" for uptime, agent health or SLA attainment per day; the mistake is hiding the counts in a table and leaving a strip of colored ticks as the only signal.
When to use
- For a per-day verdict over the last two to four weeks: supplier portal uptime, ERP sync health, first-response SLA per day.
- In agent lists and leaderboards, one bar per agent, so a bad day stands out down the column.
- When each period is a verdict against a threshold, not just a number.
- When people need the exact value for one period on hover or focus, without leaving the list.
When not to use
- For a value over time with no threshold. Show its shape with a sparkline. Use Mini chart
- For the current status only. Say it with a dot and a label. Use Status label
- For one share of a whole, such as containment this week. Use Radial chart
- For per-turn latency on one call. Use Latency timeline
- For more than about 45 periods in a narrow column. Aggregate to weeks, or draw a chart. Use Chart
The Label-Beside-Color Rule
The Ink Fill Rule
Anatomy#
28 good, 1 fair, 1 poor
- Good tick. A fully rounded bar in Ledger Green. Ticks share the width equally with 2px gaps and are 20px tall by default.
- Fair tick. Caution Amber, for a period that missed but didn't fail.
- Poor tick. Signal Red, for a period that failed its threshold.
- Summary. 12px Slate Meta counts in tabular figures, such as 12 good, 1 fair, 1 poor, or your own sentence. It describes the bar for screen readers too.
Examples#
Status names and values
statusLabels renames good, fair and poor for the metric, and formatValue writes each value with its unit. Hover a tick, or focus the bar and use the arrow keys.
Payment status agent, containment
24 healthy, 5 watch, 1 low
import { HealthBar, type HealthPoint } from "@oration/canon/components/health-bar";export function StatusNames() { const containment = [ 71, 73, 72, 70, 68, 74, 72, 71, 69, 73, 72, 74, 71, 73, 72, 71, 69, 58, 66, 73, 75, 74, 62, 71, 73, 76, 74, 72, 75, 74, ]; const points: HealthPoint[] = containment.map((value, index) => ({ label: index < 2 ? `Aug ${30 + index}` : `Sep ${index - 1}`, value, status: value >= 70 ? "good" : value >= 60 ? "fair" : "poor", })); return ( <div className="flex w-full max-w-sm flex-col gap-2 text-left"> <p className="text-13 font-medium"> Payment status agent, containment </p> <HealthBar points={points} ariaLabel="Payment status agent containment per day, Aug 30 to Sep 28" statusLabels={{ good: "Healthy", fair: "Watch", poor: "Low" }} formatValue={(value) => `${value}% contained`} /> </div> );}Summary
By default the bar counts each status underneath. Pass summary to write one sentence instead, such as the days that missed a target.
Default counts
27 good, 2 fair, 1 poor
Custom summary
Missed the 90% target on 2 of 30 days, most recently Sep 24
import { HealthBar, type HealthPoint } from "@oration/canon/components/health-bar";export function Summary() { const sla = [ 95, 96, 94, 97, 93, 95, 96, 94, 97, 95, 96, 93, 95, 97, 96, 94, 96, 94, 97, 91, 88, 95, 96, 97, 93, 82, 95, 96, 94, 97, ]; const points: HealthPoint[] = sla.map((value, index) => ({ label: index < 2 ? `Aug ${30 + index}` : `Sep ${index - 1}`, value, status: value >= 93 ? "good" : value >= 85 ? "fair" : "poor", })); return ( <div className="flex w-full max-w-sm flex-col gap-6 text-left"> <div className="flex flex-col gap-2"> <p className="text-13 font-medium">Default counts</p> <HealthBar points={points} ariaLabel="First-response SLA per day, Aug 30 to Sep 28" formatValue={(value) => `${value}% within SLA`} /> </div> <div className="flex flex-col gap-2"> <p className="text-13 font-medium">Custom summary</p> <HealthBar points={points} ariaLabel="First-response SLA per day, Aug 30 to Sep 28" formatValue={(value) => `${value}% within SLA`} summary="Missed the 90% target on 2 of 30 days, most recently Sep 24" /> </div> </div> );}Heights
height sets the tick height: 28 in a panel, 20 by default, 16 in a table row.
28, in a panel
20, the default
16, in a table row
import { HealthBar, type HealthPoint } from "@oration/canon/components/health-bar";export function Heights() { const points: HealthPoint[] = Array.from({ length: 30 }, (_, index) => { const status: HealthPoint["status"] = index === 4 || index === 23 ? "fair" : index === 15 ? "poor" : "good"; return { label: index < 2 ? `Aug ${30 + index}` : `Sep ${index - 1}`, value: status === "good" ? 99.99 : status === "fair" ? 99.3 : 97.1, status, }; }); return ( <div className="flex w-full max-w-sm flex-col gap-5 text-left"> {[28, 20, 16].map((height) => ( <div key={height} className="flex flex-col gap-1.5"> <p className="text-xs text-muted-foreground tabular-nums"> {height === 20 ? "20, the default" : height === 28 ? "28, in a panel" : "16, in a table row"} </p> <HealthBar points={points} height={height} summary={false} ariaLabel={`ERP sync uptime per day, ${height}px bar`} formatValue={(value) => `${value}% uptime`} /> </div> ))} </div> );}In an agent list
Home's agent health panel: one bar per agent between its name and latency, so a bad day stands out down the column.
Agent health
- On a callPayment status8,412 calls, 74% containedp50 820 ms
29 good, 1 fair
- Remittance questions3,106 calls, 66% containedp50 910 ms
11 good, 17 fair, 2 poor
- W-9 outreach1,248 calls, 81% containedp50 760 ms
30 good
import { AgentAvatar } from "@oration/canon/components/agent-avatar";import { HealthBar, type HealthPoint } from "@oration/canon/components/health-bar";export function AgentHealth() { const agents = [ { id: "ag_payment_status", name: "Payment status", live: true, calls: "8,412", contained: "74%", p50: "820", scores: [ 89, 91, 88, 90, 92, 87, 90, 91, 89, 93, 90, 88, 91, 92, 89, 88, 90, 86, 91, 89, 92, 90, 71, 88, 91, 93, 90, 92, 94, 93, ], }, { id: "ag_remittance", name: "Remittance questions", live: false, calls: "3,106", contained: "66%", p50: "910", scores: [ 86, 88, 85, 87, 84, 86, 82, 85, 87, 86, 84, 83, 81, 78, 74, 80, 69, 72, 76, 79, 58, 71, 77, 80, 78, 82, 84, 86, 85, 87, ], }, { id: "ag_w9_outreach", name: "W-9 outreach", live: false, calls: "1,248", contained: "81%", p50: "760", scores: [ 92, 94, 93, 95, 94, 96, 95, 94, 96, 97, 95, 96, 97, 96, 95, 94, 96, 95, 97, 96, 95, 94, 96, 97, 95, 96, 97, 96, 95, 96, ], }, ]; return ( <section aria-labelledby="agent-health" className="w-full max-w-2xl min-w-0 rounded-xl bg-card text-left shadow-border" > <h3 id="agent-health" className="px-4 pt-4 pb-2 text-sm font-semibold" > Agent health </h3> <ul className="flex flex-col px-2 pb-2"> {agents.map((agent) => { const points: HealthPoint[] = agent.scores.map( (value, index) => ({ label: index < 2 ? `Aug ${30 + index}` : `Sep ${index - 1}`, value, status: value >= 85 ? "good" : value >= 70 ? "fair" : "poor", }), ); return ( <li key={agent.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2 rounded-lg px-2 py-2.5 transition-colors duration-150 hover:bg-muted/60 md:grid-cols-[minmax(0,13rem)_minmax(0,1fr)_auto]" > <div className="flex min-w-0 items-center gap-2.5"> <AgentAvatar agent={agent} size={28} state={agent.live ? "live" : "idle"} /> <span className="flex min-w-0 flex-col"> <span className="truncate text-13 font-medium"> {agent.name} </span> <span className="truncate text-xs text-muted-foreground tabular-nums"> {agent.calls} calls, {agent.contained}{" "} contained </span> </span> </div> <HealthBar points={points} ariaLabel={`${agent.name} daily health, Aug 30 to Sep 28`} formatValue={(value) => `${value} health`} className="col-span-2 min-w-0 md:col-span-1" /> <span className="row-start-1 text-right text-xs text-muted-foreground tabular-nums md:col-start-3"> p50 {agent.p50} ms </span> </li> ); })} </ul> </section> );}No data yet
An agent with no calls gets no ticks and says so in the summary line.
Vendor onboarding agent
No data yet
import { HealthBar } from "@oration/canon/components/health-bar";export function Empty() { return ( <div className="flex w-full max-w-sm flex-col gap-2 text-left"> <p className="text-13 font-medium">Vendor onboarding agent</p> <HealthBar points={[]} ariaLabel="Vendor onboarding agent daily health" /> </div> );}States#
| State | Treatment |
|---|---|
| Rest | Every tick at full color, the summary below. |
| Hover | The hovered tick stays at full color and every other tick fades to 40% over 80ms. A tooltip shows the period, a status dot and label, the value and a small trend of the surrounding points. |
| Focus | One tick is in the tab order (the latest). Focusing it shows the same tooltip and fade as hover. |
| Keyboard moves | Arrow keys move focus tick by tick, Home and End jump to the first and last; the tooltip follows focus. |
| No summary | summary={false} hides the counts line, for table cells where the row states the status elsewhere. |
| No data | An empty points array draws no ticks and the summary reads No data yet. |
Behavior#
- Each tick is a button wrapped in a Tooltip with no open or close delay. The tooltip's trend is a Mini chart in
currentColorof up totrendWindowpoints either side, with the current point marked. - The bar uses a roving tab index: one Tab stop for the whole bar, arrow keys inside it. Focus starts on the latest point.
- Each tick's hit area extends 6px above and below, so thin ticks are still easy to point at.
statusLabelsrenames the three statuses in the tooltip, the accessible names and the default summary: Operational, Degraded and Outage, or Healthy, Watch and Low.formatValuewrites each value with its unit in tooltips and tick names, such as 97.4% uptime.summaryreplaces the counts with your own text;falseremoves the line and itsaria-describedby.- You decide each point's status. Keep the thresholds in one place so the same value never reads as good in one list and fair in another.
Do and don't#
12 good, 1 fair, 1 poor
29 good, 1 poor
87 good, 3 poor
Content#
ariaLabelnames what's measured and the window: Supplier portal uptime per day, Aug 30 to Sep 28.- Point labels are the period: Sep 21, Week of Sep 14.
- Status labels are one word, from the reader's point of view: Operational, Degraded, Outage. Keep them the same everywhere the metric appears.
- A custom summary is one sentence with figures: Missed the 90% target on 2 of 14 days, most recently Sep 24.
Accessibility#
- The bar is a
role="group"with yourariaLabel, described by the summary line. - Each tick is a button named with its label, value and status: Sep 21: 97.4% uptime, Outage.
- One tick is in the tab order; arrow keys, Home and End move between ticks, so a 30-day bar is one Tab stop.
- The tooltip repeats the status in words next to its dot.
- The counts line says the status in words for sighted readers; keep it unless the row says it another way.
- Ticks have no focus style of their own and rely on the browser's default outline, tinted Focus Indigo at 50% by the base styles.
| Keys | Action |
|---|---|
| Tab | Focuses the bar's current tick, the latest by default. |
| → | Moves to the next period. Arrow Down does the same. |
| ← | Moves to the previous period. Arrow Up does the same. |
| Home | Moves to the first period. |
| End | Moves to the latest period. |
Design tokens#
| Token | Used for |
|---|---|
--success | Good ticks |
--warning | Fair ticks |
--destructive | Poor ticks |
--muted-foreground | Summary line |
--foreground | Tooltip fill, through Tooltip |
API reference#
HealthBar
The bar, its tooltips and its summary. data-slot="health-bar".
| Prop | Type | Default | Description |
|---|---|---|---|
pointsRequired | HealthPoint[] | No default | One point per period, oldest first. |
ariaLabelRequired | string | No default | Names what's measured and the window. |
height | number | 20 | Tick height in pixels. 16 in table rows. |
statusLabels | Partial<Record<HealthStatus, string>> | { good: "Good", fair: "Fair", poor: "Poor" } | Names for each status. |
formatValue | (value: number) => string | toLocaleString("en-US") | Formats values in tooltips and tick names. |
summary | ReactNode | false | No default | Replaces the counts line. false hides it; leave it on unless the row states the status. |
trendWindow | number | 6 | Points either side of a tick in its tooltip trend. |
className | string | No default | Merged onto the outer element; set a width here. |
HealthPoint
One period. Exported as a type, with HealthStatus.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | The period, such as Sep 21. |
valueRequired | number | No default | The measured value, shown through formatValue. |
statusRequired | "good" | "fair" | "poor" | No default | The verdict for the period. You set the thresholds. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The agents table and the analytics leaderboard pass summary={false} with no status text in the row, so the ticks are color only there. That breaks the Label-Beside-Color Rule.
Ticks have no focus-visible style of their own; focus shows as the browser's default outline tinted by the base outline-ring/50, not the 3px Focus Indigo ring other controls use.
Ticks stretch to share the bar's width with no minimum or maximum. Past about 45 points in a narrow column they shrink to hairlines; a short series in a wide column, such as 14 days across 30rem, turns into round pills instead of thin ticks. Set a width on the bar to keep ticks around 4 to 8px.