Skip to content

Meter

A measured bar in ink steps with optional stacked segments and a target marker.

Status
Stable
Level
Atom
Category
Data display
Adoption
Not used yet
import { Meter } from "@oration/canon/components/meter";
packages/canon/src/components/meter.tsx

Credits this period

18,420 of 25,000 credits used

6,580 left. Resets Oct 14.

  • Invoice capture11,240
  • Supplier calls5,310
  • Workflows1,870
import { Meter } from "@oration/canon/components/meter";import { cn } from "@oration/canon/lib/utils";export function Hero() {    const segments = [        { label: "Invoice capture", value: 11240, className: "bg-ink-80" },        { label: "Supplier calls", value: 5310, className: "bg-ink-45" },        { label: "Workflows", value: 1870, className: "bg-ink-15" },    ];    return (        <div className="flex w-full max-w-md flex-col rounded-xl bg-card p-4 shadow-border">            <p className="text-sm font-medium text-foreground">                Credits this period            </p>            <p className="mt-2 text-13">                <span className="font-medium text-foreground tabular-nums">                    18,420                </span>                <span className="text-muted-foreground tabular-nums">                    {" "}                    of 25,000 credits used                </span>            </p>            <p className="text-13 text-muted-foreground tabular-nums">                6,580 left. Resets Oct 14.            </p>            <Meter                className="mt-3"                max={25000}                label="Credits used this period"                aria-valuetext="18,420 of 25,000 credits"                segments={segments}            />            <ul className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-13 text-muted-foreground">                {segments.map((segment) => (                    <li                        key={segment.label}                        className="flex items-center gap-1.5"                    >                        <span                            aria-hidden="true"                            className={cn(                                "size-2 rounded-[2px]",                                segment.className,                            )}                        />                        {segment.label}                        <span className="text-foreground tabular-nums">                            {segment.value.toLocaleString("en-US")}                        </span>                    </li>                ))}            </ul>        </div>    );}

Usage#

Meter is a round Well Gray track with an ink fill that shows a quantity against a limit: credits used this period, seats taken, a payment run's paid and scheduled totals, a forecast against its target. Pass value for one Ink 65 fill, or segments for a stack of ink steps, and target for a marker. It measures a level, not work in motion; for a task that finishes, use Progress. The mistake people make is coloring it: the fill stays ink unless the value is itself a verdict, and the words and figures beside it say what it means.

When to use

  • For a used-of-total figure with a real limit: 18,420 of 25,000 credits used, 18 of 25 seats.
  • With segments to break one total into two or three parts, such as a payment run's paid, scheduled and awaiting approval amounts, with a legend underneath.
  • With target to show a figure against a goal: straight-through processing against 80%, a quarter's forecast against its number.
  • In table cells and rails at sm beside a tabular value, for per-row shares such as an approver's queue.
  • With a semantic tone only when the value is a verdict, such as password strength or a failing score.

When not to use

  • For a task that runs and finishes, such as an import or a payment run going out. Use Progress
  • For a trend over time. A meter is one moment. Use Mini chart
  • For a single percentage that reads better as a ring on a dashboard tile. Use Radial chart
  • For status history over periods, such as a sync's uptime by day. Use Health bar
  • For comparing more than three parts or many categories. A stack past three segments is a chart. Use Chart

The Ink Fill Rule

Magnitudes are drawn in ink steps (80, 65, 45, 25, 15) on a Well Gray track. A single fill is Ink 65; a three-part stack is Ink 80, 45 and 15. A fill turns semantic only when the value is itself a verdict, such as a strong password in Ledger Green. It is never indigo.

The Label-Beside-Color Rule

The bar is the picture; the figure is the fact. Put the value in words beside it (18 of 25 seats), and give every segment a legend entry with its tabular value.

The Tabular Figures Rule

The value, the limit and every legend figure are set in tabular figures, so they line up across rows and don't jitter as they change.

Anatomy#

Paid$642,300
  1. Track. The root: a full-width Well Gray bar with round ends, 4, 6 or 10px tall. Carries role="meter" and the ARIA values.
  2. Fill. With value, one round Ink 65 fill (or the tone color) clipped to the track, its width a percentage of max.
  3. Segments. With segments, flat fills laid end to end in order. Each takes its color from its own className; set ink steps.
  4. Target marker. A 2px Graphite Ink bar with a 2px card-colored ring, 4px past the track top and bottom, at target.
  5. Value and legend. Not part of the component. The figure above it in Body Dense tabular, and for stacks an 8px square swatch, a label and a tabular value per segment.

Examples#

Single value

value and max draw one Ink 65 fill. The figure sits above it in words, and label repeats it for screen readers.

Seats18 of 25 seats
Supplier portal invites312 of 500 invites
Stored remittances41 of 100 GB
import { Meter } from "@oration/canon/components/meter";export function SingleValue() {    const rows = [        { name: "Seats", used: 18, max: 25, unit: "seats" },        {            name: "Supplier portal invites",            used: 312,            max: 500,            unit: "invites",        },        { name: "Stored remittances", used: 41, max: 100, unit: "GB" },    ];    return (        <div className="flex w-full max-w-sm flex-col gap-4">            {rows.map((row) => (                <div key={row.name} className="flex flex-col gap-2">                    <div className="flex items-baseline justify-between gap-3 text-13">                        <span className="font-medium text-foreground">                            {row.name}                        </span>                        <span className="text-muted-foreground tabular-nums">                            {row.used} of {row.max} {row.unit}                        </span>                    </div>                    <Meter                        value={row.used}                        max={row.max}                        label={`${row.name}, ${row.used} of ${row.max} ${row.unit} used`}                    />                </div>            ))}        </div>    );}

Stacked segments

segments lays up to three parts end to end in Ink 80, 45 and 15, with a legend of 8px square swatches and tabular values in the same order.

Friday freight run

$1,200,000 to 48 suppliers

  • Paid$842,300
  • Scheduled$296,150
  • Awaiting approval$61,550
import { Meter } from "@oration/canon/components/meter";import { cn } from "@oration/canon/lib/utils";export function Stacked() {    const segments = [        {            label: "Paid",            value: 842300,            amount: "$842,300",            className: "bg-ink-80",        },        {            label: "Scheduled",            value: 296150,            amount: "$296,150",            className: "bg-ink-45",        },        {            label: "Awaiting approval",            value: 61550,            amount: "$61,550",            className: "bg-ink-15",        },    ];    return (        <div className="flex w-full max-w-md flex-col gap-3">            <div className="flex items-baseline justify-between gap-3">                <p className="text-13 font-medium text-foreground">                    Friday freight run                </p>                <p className="text-13 text-muted-foreground tabular-nums">                    $1,200,000 to 48 suppliers                </p>            </div>            <Meter                size="lg"                max={1200000}                label="Friday freight run by payment status"                aria-valuetext="$1,200,000 of $1,200,000"                segments={segments}            />            <ul className="flex flex-wrap gap-x-5 gap-y-1 text-13 text-muted-foreground">                {segments.map((segment) => (                    <li                        key={segment.label}                        className="flex items-center gap-1.5"                    >                        <span                            aria-hidden="true"                            className={cn(                                "size-2 rounded-[2px]",                                segment.className,                            )}                        />                        {segment.label}                        <span className="text-foreground tabular-nums">                            {segment.amount}                        </span>                    </li>                ))}            </ul>        </div>    );}

Target

target draws a 2px ink marker that stands 4px proud of the track. Label it in 11px Footnote underneath; the marker itself is hidden from screen readers.

Straight-through processing72%
Early-pay discounts captured$51,800, goal $45,000
import { Meter } from "@oration/canon/components/meter";export function Target() {    return (        <div className="flex w-full max-w-md flex-col gap-8">            <div className="flex flex-col gap-2">                <div className="flex items-baseline justify-between gap-3 text-13">                    <span className="font-medium text-foreground">                        Straight-through processing                    </span>                    <span className="text-muted-foreground tabular-nums">                        72%                    </span>                </div>                <Meter                    value={72}                    max={100}                    target={80}                    label="Straight-through processing, 72% against an 80% target"                />                <div                    aria-hidden="true"                    className="relative h-4 text-[11px] leading-4 text-muted-foreground"                >                    <span className="absolute left-[80%] -translate-x-1/2 tabular-nums">                        Target 80%                    </span>                </div>            </div>            <div className="flex flex-col gap-2">                <div className="flex items-baseline justify-between gap-3 text-13">                    <span className="font-medium text-foreground">                        Early-pay discounts captured                    </span>                    <span className="text-muted-foreground tabular-nums">                        $51,800, goal $45,000                    </span>                </div>                <Meter                    value={51800}                    max={60000}                    target={45000}                    label="Early-pay discounts, $51,800 captured against a $45,000 goal"                />                <div                    aria-hidden="true"                    className="relative h-4 text-[11px] leading-4 text-muted-foreground"                >                    <span className="absolute left-[75%] -translate-x-1/2 tabular-nums">                        Q3 goal $45,000                    </span>                </div>            </div>        </div>    );}

Sizes

4px sm for table cells and rails, 6px by default in settings and cards, 10px lg for a headline stack such as a forecast.

Small, 4px
Default, 6px
Large, 10px
import { Meter } from "@oration/canon/components/meter";export function Sizes() {    const sizes = [        { size: "sm", name: "Small, 4px" },        { size: "default", name: "Default, 6px" },        { size: "lg", name: "Large, 10px" },    ] as const;    return (        <div className="grid w-full max-w-md grid-cols-[6rem_1fr] items-center gap-x-4 gap-y-5">            {sizes.map((item) => (                <div key={item.size} className="contents">                    <span className="text-13 text-muted-foreground">                        {item.name}                    </span>                    <Meter                        size={item.size}                        value={18}                        max={25}                        label={`${item.name} meter, 18 of 25 seats used`}                    />                </div>            ))}        </div>    );}

Tones

neutral is Ink 65 and is the default. success, warning and danger are for values that are verdicts, never for a plain quantity.

Neutral
Success
Warning
Danger
import { Meter } from "@oration/canon/components/meter";export function Tones() {    const tones = [        { tone: "neutral", name: "Neutral", value: 3 },        { tone: "success", name: "Success", value: 4 },        { tone: "warning", name: "Warning", value: 2 },        { tone: "danger", name: "Danger", value: 1 },    ] as const;    return (        <div className="grid w-full max-w-md grid-cols-[6rem_1fr] items-center gap-x-4 gap-y-5">            {tones.map((item) => (                <div key={item.tone} className="contents">                    <span className="text-13 text-muted-foreground">                        {item.name}                    </span>                    <Meter                        tone={item.tone}                        value={item.value}                        max={4}                        label={`${item.name} tone, ${item.value} of 4`}                    />                </div>            ))}        </div>    );}

Password strength

The verdict case from account security: four checks set the value, the tone follows the verdict and the word beside it is announced politely.

Weak

12 characters with a capital, a number and a symbol.

import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { Meter } from "@oration/canon/components/meter";import * as React from "react";export function PasswordStrength() {    const id = React.useId();    const [password, setPassword] = React.useState("northwind");    const checks = [        password.length >= 12,        /[A-Z]/.test(password),        /\d/.test(password),        /[^A-Za-z0-9]/.test(password),    ];    const score = checks.filter(Boolean).length;    const level =        score <= 1            ? { label: "Weak", tone: "danger" as const }            : score <= 3              ? { label: "Fair", tone: "warning" as const }              : { label: "Strong", tone: "success" as const };    return (        <div className="flex w-full max-w-xs flex-col gap-2">            <Label htmlFor={id}>New password</Label>            <Input                id={id}                type="text"                autoComplete="off"                value={password}                onChange={(event) => setPassword(event.target.value)}            />            <div className="flex items-center gap-3">                <Meter                    value={score}                    max={4}                    tone={password ? level.tone : "neutral"}                    label="Password strength"                    aria-valuetext={password ? level.label : "Empty"}                    className="flex-1"                />                <span                    aria-live="polite"                    className="w-14 text-right text-xs text-muted-foreground"                >                    {password ? level.label : ""}                </span>            </div>            <p className="text-xs text-muted-foreground">                12 characters with a capital, a number and a symbol.            </p>        </div>    );}

In a table

At sm in a fixed-width cell, with the count in its own right-aligned tabular column so rows compare at a glance.

Approval queues this week
ApproverQueueInvoices
Maya Okafor
18 of 40
Priya Raman
31 of 40
Tomás Ferreira
9 of 40
Jordan Lee
40 of 40
import { Meter } from "@oration/canon/components/meter";export function InATable() {    const approvers = [        { name: "Maya Okafor", queued: 18 },        { name: "Priya Raman", queued: 31 },        { name: "Tomás Ferreira", queued: 9 },        { name: "Jordan Lee", queued: 40 },    ];    return (        <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card shadow-border">            <table className="w-full text-13">                <caption className="sr-only">Approval queues this week</caption>                <thead>                    <tr className="h-8 border-b border-border text-left text-muted-foreground">                        <th scope="col" className="px-3 font-medium">                            Approver                        </th>                        <th scope="col" className="px-3 font-medium">                            Queue                        </th>                        <th scope="col" className="px-3 text-right font-medium">                            Invoices                        </th>                    </tr>                </thead>                <tbody>                    {approvers.map((approver) => (                        <tr                            key={approver.name}                            className="h-9 border-b border-border last:border-b-0"                        >                            <td className="px-3 text-foreground">                                {approver.name}                            </td>                            <td className="px-3">                                <Meter                                    size="sm"                                    value={approver.queued}                                    max={40}                                    label={`${approver.name}, ${approver.queued} of 40 invoices queued`}                                    className="w-28"                                />                            </td>                            <td className="px-3 text-right text-foreground tabular-nums">                                {approver.queued} of 40                            </td>                        </tr>                    ))}                </tbody>            </table>        </div>    );}

Changing value

The fill glides over 500ms on the house ease-out when the value moves. Keep the words beside it in step.

Seats18 of 25 used, 7 left
import { Button } from "@oration/canon/components/button";import { Meter } from "@oration/canon/components/meter";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Changing() {    const [used, setUsed] = React.useState(18);    const left = 25 - used;    return (        <div className="flex w-full max-w-sm flex-col gap-3">            <div className="flex items-baseline justify-between gap-3 text-13">                <span className="font-medium text-foreground">Seats</span>                <span className="text-muted-foreground tabular-nums">                    {used} of 25 used, {left} left                </span>            </div>            <Meter value={used} max={25} label={`${used} of 25 seats used`} />            <div className="flex gap-2">                <Button                    type="button"                    variant="outline"                    size="sm"                    disabled={used >= 25}                    onClick={() => {                        const next = Math.min(25, used + 3);                        setUsed(next);                        if (next === 25) {                            toast.add({                                title: "All 25 seats are taken",                                description:                                    "Add seats in billing to invite more teammates.",                            });                        }                    }}                >                    Invite 3 teammates                </Button>                <Button                    type="button"                    variant="ghost"                    size="sm"                    disabled={used === 18}                    onClick={() => setUsed(18)}                >                    Reset                </Button>            </div>        </div>    );}

States#

Empty
None used
Partial
11 of 25
Full
25 of 25
Over the limit
Over by 4
With a target
Target 20
import { Meter } from "@oration/canon/components/meter";export function StatesGrid() {    const states = [        { name: "Empty", value: 0, note: "None used" },        { name: "Partial", value: 11, note: "11 of 25" },        { name: "Full", value: 25, note: "25 of 25" },        { name: "Over the limit", value: 29, note: "Over by 4" },    ];    return (        <div className="grid w-full max-w-lg grid-cols-[7rem_1fr_5.5rem] items-center gap-x-4 gap-y-5">            {states.map((state) => (                <div key={state.name} className="contents">                    <span className="text-13 text-muted-foreground">                        {state.name}                    </span>                    <Meter                        value={state.value}                        max={25}                        label={`${state.name}, ${state.note}`}                    />                    <span className="text-right text-13 text-foreground tabular-nums">                        {state.note}                    </span>                </div>            ))}            <span className="text-13 text-muted-foreground">With a target</span>            <Meter                value={11}                max={25}                target={20}                label="11 of 25, target 20"            />            <span className="text-right text-13 text-foreground tabular-nums">                Target 20            </span>        </div>    );}
States
StateTreatment
Emptyvalue={0}. The track alone. Say None used beside it rather than hiding the meter.
PartialThe fill covers value / max of the track.
FullAt or over max the fill is clamped to the whole track. aria-valuenow still reports the real total, so say Over by 1,200 in words.
Against a targetThe marker sits at target whether the fill has passed it or not. Label the target in 11px Footnote under it.
Verdicttone of success, warning or danger swaps the single fill to Ledger Green, Amber or Signal Red. Segments ignore tone when they set their own class.
ChangingWidth changes animate over 500ms on the house ease-out, the one fill allowed past 320ms.

Behavior#

  • max is required. Every width is value / max, clamped between 0% and 100%.
  • With segments, each segment's width is its own value / max, laid left to right in array order. aria-valuenow is their sum.
  • value is ignored when segments is passed.
  • A segment without a className falls back to the tone color, so an unstyled stack reads as one bar. Give each segment an ink step.
  • The target is aria-hidden; say what it is in text nearby.
  • The track is overflow-visible so the target marker can extend past it; the fills sit in an inner clipping layer with round ends.
  • Width transitions are 500ms on --ease-out (cubic-bezier(0.23, 1, 0.32, 1)), so a changing value glides rather than jumps.
  • Rest props spread onto the root after the ARIA attributes, so aria-valuetext or id pass straight through.

Do and don't#

Paid, scheduled, awaiting approval

Do. Fill with ink steps: Ink 65 for one value, Ink 80, 45 and 15 for a stack.

Paid, scheduled, awaiting approval

Don't. Fill with indigo or categorical hues. Indigo is for selection and the primary action, and hues turn a quantity into categories.
Seats18 of 25, 7 left
Do. Put the figure in words beside the bar: 18 of 25 seats, 7 left.
Don't. Show a bare bar. Nobody can read 72% off a track, and the limit is invisible.
Password strength: strong
23 of 25 seats
Do. Use a tone only when the value is a verdict, such as password strength.
23 of 25 seats
312 of 500 invites
Don't. Color a plain quantity by how full it is. Seats used aren't good or bad, and green for a nearly full plan reads as a pass.

Content#

  • Lead with the used figure and the limit in tabular figures: 18,420 of 25,000 credits used.
  • Add the remainder and the reset when there is one, in Slate Meta: 6,580 left. Resets Oct 14.
  • The label prop is what screen readers hear. Write the full sentence: 18 of 25 seats used, not Seats.
  • Legend entries are the segment's name and its value, in the same order as the stack: Paid $842,300.
  • Label a target by what it is and its figure: Target 80%, Q3 number $2.4M.

Accessibility#

  • The root is role="meter" with aria-label, aria-valuemin={0}, aria-valuemax and aria-valuenow. Support for the ARIA 1.2 meter role varies; some screen readers announce it as a progress bar or read only the name, so repeat the figure in visible text.
  • Pass aria-valuetext for units: 18,420 of 25,000 credits. Without it the number is read bare.
  • The target marker is aria-hidden; state the target in text.
  • Segments are not announced individually. The legend carries each part in text.
  • Ink steps invert with the theme, so the stack keeps its contrast in dark mode. Don't rely on the lightest step alone to carry a meaning.
  • The width transition only moves the fill and isn't turned off under reduced motion.

Design tokens#

Design tokens
TokenUsed for
--mutedTrack (Well Gray)
--color-ink-65 (bg-foreground/65)Default single fill
--color-ink-80, --color-ink-45, --color-ink-15Three-part stacks, passed as segment classes
--success, --warning, --destructiveVerdict tones
--foregroundTarget marker
--card2px ring around the target marker
--ease-out500ms width transition
h-1, h-1.5, h-2.5, rounded-full4, 6 and 10px round track

API reference#

Meter

The meter. Also exported: the MeterSegment type.

Other props spread onto <div> (all props except children).

Props of Meter
PropTypeDefaultDescription
maxRequirednumberNo defaultThe limit. Widths are a share of it.
labelRequiredstringNo defaultThe accessible name, set as aria-label.
valuenumber0A single fill. Ignored when segments is set.
segmentsMeterSegment[]No defaultA stacked fill, left to right. Their sum is the value.
targetnumberNo defaultDraws a marker at this value.
size"sm" | "default" | "lg""default"Track height: 4, 6 or 10px.
tone"success" | "warning" | "danger" | "neutral""neutral"Fill color. neutral is Ink 65. The others are for verdicts only.
classNamestringNo defaultOn the track, merged last. Use it for width and margin.

MeterSegment

One part of a stacked meter.

Props of MeterSegment
PropTypeDefaultDescription
valueRequirednumberNo defaultIts size, in the same units as max.
labelRequiredstringNo defaultIts name. Also the React key, so keep it unique.
classNamestringNo defaultIts fill, such as bg-ink-80. Falls back to the tone color.

Known gaps#

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

Usage settings stack credits in bg-chart-1, bg-chart-2 and bg-chart-3 (graphite, teal and amber) with round legend dots. DESIGN.md asks for ink steps and 8px square swatches. The forecast on Home follows the rule.

Contact center ships its own Meter in contact-center/ui.tsx that takes a 0 to 1 value and no max; its roster, queue, disposition and wrap-up views use that one instead.

Segments that add up past max aren't scaled down: each is clamped on its own and the overflow is clipped, so the last segments disappear without a sign.

The target's ring is ring-card. On a tint well or the rail it draws a white halo instead of matching the surface.

Segments are keyed by label, so two segments with the same label collide.