Meter
Gauge displaying a known value within a fixed range — battery, disk usage, score, capacity. Linear or circular, four sizes, full intent palette with optional threshold-based auto-coloring, locale-aware formatting, and a multi-segment MeterGroup variant for stacked usage.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Meter, MeterGroup } from "@tessinaui/ui";{/* Basic */}
<Meter value={68} label="Storage" />
{/* Circular */}
<Meter type="circular" value={72} label="CPU" valuePosition="inside" />
{/* Threshold-based auto colour */}
<Meter
value={92}
label="Disk usage"
thresholds={[
{ at: 50, intent: "none" },
{ at: 80, intent: "warning" },
{ at: 101, intent: "error" },
]}
/>
{/* Locale-aware formatting */}
<Meter
value={186.4}
max={500}
label="Disk"
format={{ style: "unit", unit: "gigabyte", maximumFractionDigits: 1 }}
/>
{/* Custom range — rating */}
<Meter value={4.3} max={5} label="Rating" intent="success" showFraction />
{/* Multi-segment */}
<MeterGroup
max={500}
showTotal
format={{ style: "unit", unit: "gigabyte", maximumFractionDigits: 1 }}
segments={[
{ label: "Photos", value: 124, intent: "success" },
{ label: "Videos", value: 86.5, intent: "error" },
{ label: "Music", value: 42.3, intent: "info" },
{ label: "Other", value: 31.2, intent: "none" },
]}
/>Examples
Default
A basic linear meter with a label and right-aligned value.
Sizes
Five track heights — xs, sm, md, lg, and xl. xs is the hairline rule
that reads as part of a dense row rather than as a bar of its own.
Intents
The house intent vocabulary: none (the neutral default), info, success,
warning, error.
Thresholds
The indicator recolors itself as the value crosses breakpoints — error, then warning, then success.
Circular
The ring variant with the value centered inside via type="circular" and valuePosition="inside".
Multi-segment
A MeterGroup stacks several colored segments into one bar with a legend and total.
Storage breakdown
showRemaining names the unfilled part of the track. "How much is left" is the
number a storage or seat meter actually gets read for, and it is the one a
stacked bar cannot show on its own.
Status and icons
icon puts a glyph in the label row. The status glyph is automatic whenever
thresholds are in play (or the intent is error/warning) and the meter
shows a label, so the state is never carried by fill colour alone — see
Contrast below. It captions the label, so a label-less meter shows
colour and value text only; pass statusIcon to force it, or
statusIcon={false} to opt out.
Target marks
marks places static reference ticks on the track — a goal, a budget, a limit.
Scale labels
minLabel and maxLabel name the ends of the range. Apple's guidance is to
describe the current value and both endpoints; a screen reader reads the
visible labels.
Segmented
segments renders the track as N discrete cells that fill completely, never
partially. This is the shape a battery, a signal strength or a password meter
wants — distinct from MeterGroup, which divides one bar proportionally.
Password strength
The canonical segmented meter: a 0–4 score (the range zxcvbn returns) across
four cells, with the strength stated in text rather than by colour alone.
Quota and over-capacity
A value above max clamps the bar — aria-valuenow must stay inside the range —
but the text stays truthful and the root carries data-exceeded.
Caption row
description is the helper line under the track. With valuePosition="below"
it shares that row: helper at the start, value at the end.
Value positions
right (default), below, inside and none. inside needs a track tall
enough to hold text, so at xs–md it falls back to right rather than
dropping the value.
Formatting
format and locale take Intl.NumberFormat options. Whatever the meter
shows, it announces the same thing — see the accessibility note below.
Comparison list
Several meters in a row with aligned label and value columns — the most common shape a meter appears in.
In a table
xs plus labelPosition="none" gives a dense in-cell meter. The row already
names the thing, so the meter takes its accessible name from label without
drawing it.
Empty and unknown
Zero renders an empty track; a tiny nonzero value still paints. A quantity with no meaningful maximum is not a meter at all — ARIA says so — so show the number instead.
Loading
Meter vs Progress
The two components look similar but communicate different things:
| Meter | Progress | |
|---|---|---|
| Purpose | A known value within a fixed range | Task completion working toward 100% |
| Examples | Battery 78%, Disk 320 GB / 512 GB, Score 4.2 / 5, RAM 6.2 GB / 16 GB | File upload, multi-step form, page load |
| Range | Custom min / max (default 0–100) | Always 0–100 |
| Indeterminate | No — value must be known | Yes — indeterminate prop |
| ARIA role | role="meter" | role="progressbar" |
Use Meter when the value already exists and isn't going to change as the user waits. Use Progress when something is loading.
API Reference — Meter
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | The current value (clamped between min and max) |
min | number | 0 | Minimum |
max | number | 100 | Maximum |
type | "linear" | "circular" | "linear" | Bar or ring |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Size token |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Corner radius (linear only — a ring has no corners) |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic meaning (§1). none renders the neutral primary fill |
variant | "solid" | "soft" | "solid" | Visual weight — soft uses the *-light token |
thresholds | Array<{ at: number; intent: MeterIntent }> | — | Auto-recolor based on value crossing breakpoints |
label | ReactNode | — | Visible label rendered above (linear) or below (circular) the meter |
labelPosition | "outside" | "none" | "outside" | Whether the label is drawn. It still names the meter when hidden |
valuePosition | "right" | "below" | "inside" | "none" | "right" | Where the formatted value renders. inside falls back to right below lg |
showFraction | boolean | false | Render as ${value}/${max} instead of ${value} |
format | Intl.NumberFormatOptions | percent of range | Locale-aware formatting (currency, units, compact). Without it the value reads as its percent of the min–max range — 712 on 300–900 is 69% |
locale | Intl.LocalesArgument | runtime | BCP-47 locale tag |
renderValue | (formatted: string, value: number) => ReactNode | — | Fully custom value rendering — overrides format / showFraction |
getAriaValueText | (formatted: string, value: number) => string | — | What assistive tech announces. Required alongside renderValue |
description | ReactNode | — | Helper line under the track. Carbon: optional, except on an error state |
icon | ReactNode | — | Leading glyph in the label row |
statusIcon | ReactNode | boolean | auto | Status glyph, rendered beside the label. Defaults to the resolved intent's glyph when thresholds are in play or the intent is error/warning and a label is visible; true forces it without one, false opts out |
minLabel / maxLabel | ReactNode | — | Scale endpoint labels under the track |
marks | Array<{ at: number; label?: ReactNode }> | — | Static reference ticks — a goal, a limit, a target |
segments | number | — | Render the track as N discrete cells that fill completely (linear only) |
children | ReactNode | — | Custom content centred in the ring (type="circular") |
dir | "ltr" | "rtl" | inherited | Text direction |
className | string | — | Additional classes on the root |
Deprecated, one minor. intent="primary" and intent="neutral" both render
as none (they were measured identical in dark and near-identical in light), and
tone is the old name for variant — §1 reserves tone for default | on-color.
Both still work and dev-warn once.
Thresholds
thresholds lets the meter recolour itself based on the current value. The lowest at whose value is greater than the current value wins. If no threshold matches, the base intent is used. at is on the meter's own min–max scale, like marks — a 90% threshold on a 0–10 meter is at: 9.
<Meter
value={92}
thresholds={[
{ at: 30, intent: "error" }, // 0–29 → error
{ at: 70, intent: "warning" }, // 30–69 → warning
{ at: 101, intent: "success" }, // 70–100 → success
]}
/>For an inverted scale (e.g. battery — low is bad), reverse the order:
<Meter
value={15}
thresholds={[
{ at: 20, intent: "error" },
{ at: 50, intent: "warning" },
{ at: 101, intent: "success" },
]}
/>API Reference — MeterGroup
| Prop | Type | Default | Description |
|---|---|---|---|
segments | Array<MeterGroupSegment> | — | Stacked segments that fill the bar |
min | number | 0 | Minimum value of the scale |
max | number | "sum" | 100 | Max value, or "sum" to derive from segment values |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Size token |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Track corner radius |
label | ReactNode | — | Label rendered above the bar — also the accessible name |
legendPosition | "above" | "below" | "none" | "below" | Where to render the segment legend |
showTotal | boolean | false | Render ${total} / ${max} next to the label |
showRemaining | boolean | false | Add a "remaining" entry to the legend for the unfilled track. Ignored when max="sum" |
remainingLabel | ReactNode | "Remaining" | Label for that entry |
format | Intl.NumberFormatOptions | — | Format the total + each legend value |
locale | Intl.LocalesArgument | runtime | BCP-47 locale tag |
dir | "ltr" | "rtl" | inherited | Text direction |
MeterGroupSegment
| Field | Type | Description |
|---|---|---|
value | number | Segment value (in same scale as min/max) |
label | ReactNode | Display label in the legend |
intent | MeterIntent | Colour for this segment (default "none") |
variant | "solid" | "soft" | Visual weight (default "solid") |
tone | "solid" | "soft" | Saturation (default "solid") |
icon | ReactNode | Optional icon next to the legend label |
Notes
- Accessibility — built on
@base-ui/react/meter. The container renders withrole="meter"andaria-valuemin/aria-valuemax/aria-valuenow/aria-valuetextso assistive technologies announce the value correctly. - Locale-aware aria-valuetext —
aria-valuetextcarries the sameIntl.NumberFormattext the meter shows: the percent of themin–maxrange by default, or the value in yourformat. - Every descendant of a meter is presentational. ARIA forces
role="presentation"on everything insiderole="meter", so the visible value is not announced on its own — it only reaches assistive tech througharia-valuetext.showFractionandformathandle this for you; if you passrenderValue, passgetAriaValueTextwith it or the screen and the screen reader will disagree. - A meter is not focusable and carries no keyboard interaction, no
aria-liveand no interactive children — a control inside a meter is unreachable. - Colour is never the only signal. When
thresholdsare in play and a label is visible, the status glyph renders automatically, because several intent fills do not clear the 3:1 non-text contrast floor against the track — see Contrast. A label-less meter leans on its value text instead, so keep that visible. - Inside value — only
lgandxllinear sizes render an inline value; below that it falls back torightrather than dropping the value. - RTL — the linear track is mirrored via
transform: scaleX(-1). The inside-value text is un-mirrored so it reads correctly. max="sum"onMeterGroup— convenient when you want the bar to be exactly as long as the data fills it (e.g. a triage panel showing exact bug counts), rather than a fixed scale.segmentsmeans two different things, deliberately: onMeterit is a number of discrete cells (the Progress-family meaning), and onMeterGroupit is the array of proportional parts.- We render
div[role="meter"], not the native<meter>element. MDN's stated best practice is the native element, but it is effectively unstyleable across engines (::-webkit-meter-*only) — which is why Base UI, React Aria and Spectrum all make the same deviation.
Contrast
Meter's fill inherits the shared intent tokens and its track is bg-secondary,
and several of those pairs do not clear the 3:1 that WCAG 1.4.11 asks of a
meaningful graphic. Measured on the live component, both themes:
| Fill | Light | Dark |
|---|---|---|
none (primary) | 14.2:1 | 14.5:1 |
info | 4.1:1 | 5.7:1 |
error | 3.8:1 | 2.8:1 |
success | 2.6:1 | 6.8:1 |
warning | 1.7:1 | 8.9:1 |
variant="soft", any intent | 1.2:1 | 1.0:1 |
Light-theme amber cannot reach 3:1 against the track even at full saturation, so this is not a tuning miss — it is the same physics the design system already records for the amber presence dot. Two consequences for consumers:
- Never let the fill be the only carrier. Keep the value text visible, or
keep the automatic status glyph. Both are on by default — but the glyph
needs a visible label, so a label-less meter must keep its value text (or
set
statusIcon). variant="soft"is a de-emphasis treatment, not a way to show a value. Use it where the number beside it does the talking.
Retuning the shared tokens is a system-level decision, tracked separately.