Skip to content

Health bar

A row of verdict ticks, one per period, with the counts written beside it.

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

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

Ticks are colored, so the counts are written beside them by default. When you turn the summary off in a table, the row must say the status in words elsewhere.

The Ink Fill Rule

Health ticks are one of the places a fill turns semantic, because each period is a verdict. Ledger Green for good, Caution Amber for fair, Signal Red for poor; never indigo or a categorical hue.

Anatomy#

28 good, 1 fair, 1 poor

  1. Good tick. A fully rounded bar in Ledger Green. Ticks share the width equally with 2px gaps and are 20px tall by default.
  2. Fair tick. Caution Amber, for a period that missed but didn't fail.
  3. Poor tick. Signal Red, for a period that failed its threshold.
  4. 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% contained

    29 good, 1 fair

    p50 820 ms
  • Remittance questions3,106 calls, 66% contained

    11 good, 17 fair, 2 poor

    p50 910 ms
  • W-9 outreach1,248 calls, 81% contained

    30 good

    p50 760 ms
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#

States
StateTreatment
RestEvery tick at full color, the summary below.
HoverThe 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.
FocusOne tick is in the tab order (the latest). Focusing it shows the same tooltip and fade as hover.
Keyboard movesArrow keys move focus tick by tick, Home and End jump to the first and last; the tooltip follows focus.
No summarysummary={false} hides the counts line, for table cells where the row states the status elsewhere.
No dataAn 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 currentColor of up to trendWindow points 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.
  • statusLabels renames the three statuses in the tooltip, the accessible names and the default summary: Operational, Degraded and Outage, or Healthy, Watch and Low.
  • formatValue writes each value with its unit in tooltips and tick names, such as 97.4% uptime.
  • summary replaces the counts with your own text; false removes the line and its aria-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

Do. Keep the counts under the bar, so the status reads in words.
Don't. Turn the summary off and leave the ticks alone. Red and green are the only signal, and they fail in grayscale.

29 good, 1 poor

Do. Size the bar so each tick is about 4 to 8px wide: 30 days in 15 to 20rem.

87 good, 3 poor

Don't. Squeeze 90 days into 10rem. Ticks turn into hairlines that can't be seen or pointed at.

Content#

  • ariaLabel names 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 your ariaLabel, 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.
Keyboard interactions
KeysAction
TabFocuses 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.
HomeMoves to the first period.
EndMoves to the latest period.

Design tokens#

Design tokens
TokenUsed for
--successGood ticks
--warningFair ticks
--destructivePoor ticks
--muted-foregroundSummary line
--foregroundTooltip fill, through Tooltip

API reference#

HealthBar

The bar, its tooltips and its summary. data-slot="health-bar".

Props of HealthBar
PropTypeDefaultDescription
pointsRequiredHealthPoint[]No defaultOne point per period, oldest first.
ariaLabelRequiredstringNo defaultNames what's measured and the window.
heightnumber20Tick height in pixels. 16 in table rows.
statusLabelsPartial<Record<HealthStatus, string>>{ good: "Good", fair: "Fair", poor: "Poor" }Names for each status.
formatValue(value: number) => stringtoLocaleString("en-US")Formats values in tooltips and tick names.
summaryReactNode | falseNo defaultReplaces the counts line. false hides it; leave it on unless the row states the status.
trendWindownumber6Points either side of a tick in its tooltip trend.
classNamestringNo defaultMerged onto the outer element; set a width here.

HealthPoint

One period. Exported as a type, with HealthStatus.

Props of HealthPoint
PropTypeDefaultDescription
labelRequiredstringNo defaultThe period, such as Sep 21.
valueRequirednumberNo defaultThe measured value, shown through formatValue.
statusRequired"good" | "fair" | "poor"No defaultThe 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.