Meter
A measured bar in ink steps with optional stacked segments and a target marker.
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
segmentsto 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
targetto show a figure against a goal: straight-through processing against 80%, a quarter's forecast against its number. - In table cells and rails at
smbeside a tabular value, for per-row shares such as an approver's queue. - With a semantic
toneonly 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
The Label-Beside-Color Rule
The Tabular Figures Rule
Anatomy#
- Track. The root: a full-width Well Gray bar with round ends, 4, 6 or 10px tall. Carries
role="meter"and the ARIA values. - Fill. With
value, one round Ink 65 fill (or the tone color) clipped to the track, its width a percentage ofmax. - Segments. With
segments, flat fills laid end to end in order. Each takes its color from its ownclassName; set ink steps. - Target marker. A 2px Graphite Ink bar with a 2px card-colored ring, 4px past the track top and bottom, at
target. - 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.
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.
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.
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.
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.
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.
| Approver | Queue | Invoices |
|---|---|---|
| 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.
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#
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> );}| State | Treatment |
|---|---|
| Empty | value={0}. The track alone. Say None used beside it rather than hiding the meter. |
| Partial | The fill covers value / max of the track. |
| Full | At 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 target | The marker sits at target whether the fill has passed it or not. Label the target in 11px Footnote under it. |
| Verdict | tone 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. |
| Changing | Width changes animate over 500ms on the house ease-out, the one fill allowed past 320ms. |
Behavior#
maxis required. Every width isvalue / max, clamped between 0% and 100%.- With
segments, each segment's width is its ownvalue / max, laid left to right in array order.aria-valuenowis their sum. valueis ignored whensegmentsis passed.- A segment without a
classNamefalls 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-visibleso 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-valuetextoridpass straight through.
Do and don't#
Paid, scheduled, awaiting approval
Paid, scheduled, awaiting approval
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
labelprop 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"witharia-label,aria-valuemin={0},aria-valuemaxandaria-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-valuetextfor 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#
| Token | Used for |
|---|---|
--muted | Track (Well Gray) |
--color-ink-65 (bg-foreground/65) | Default single fill |
--color-ink-80, --color-ink-45, --color-ink-15 | Three-part stacks, passed as segment classes |
--success, --warning, --destructive | Verdict tones |
--foreground | Target marker |
--card | 2px ring around the target marker |
--ease-out | 500ms width transition |
h-1, h-1.5, h-2.5, rounded-full | 4, 6 and 10px round track |
API reference#
Meter
The meter. Also exported: the MeterSegment type.
Other props spread onto <div> (all props except children).
| Prop | Type | Default | Description |
|---|---|---|---|
maxRequired | number | No default | The limit. Widths are a share of it. |
labelRequired | string | No default | The accessible name, set as aria-label. |
value | number | 0 | A single fill. Ignored when segments is set. |
segments | MeterSegment[] | No default | A stacked fill, left to right. Their sum is the value. |
target | number | No default | Draws 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. |
className | string | No default | On the track, merged last. Use it for width and margin. |
MeterSegment
One part of a stacked meter.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | number | No default | Its size, in the same units as max. |
labelRequired | string | No default | Its name. Also the React key, so keep it unique. |
className | string | No default | Its 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.