Meter
Gauge displaying a known value within a fixed range — battery, disk usage, score, capacity. Linear, circular or an open 180°/270° arc, a fill or a marker, qualitative colour zones, full intent palette with optional threshold-based auto-coloring, locale-aware formatting, and a multi-segment MeterGroup variant for stacked usage.
pnpm add @tessinaui/uiWhen to use
- A known value within a fixed range. Battery, disk usage, a score out of five, seats used: the value already exists and will not change while the user waits.
- A value that should change colour as it crosses a limit.
thresholdsrecolour the meter, andmarksandzonesshow where a goal, a limit or a healthy range sits. - A reading of capacity.
MeterGroupstacks parts of a whole, such as storage by type, with a legend and an optional remaining entry. - A compact reading in a row or a table cell.
xswithlabelPosition="none"gives a dense in-cell meter; the label still names it. - A gauge.
type="circular"gives a ring, andsweepopens it into a 270 or 180 degree arc with end labels. - A value with a visible label and number. Show the formatted value beside the bar so the reading never depends on colour alone.
When not to use
- For work in flight. An upload, a multi-step form or a page load moves toward done: use Progress, which can also be indeterminate (Meter vs Progress).
- For a quantity with no meaningful maximum. A meter needs a range; show the number instead (Empty and unknown).
- To let people set a value. A meter is a static reading with no keyboard interaction; use Slider.
- For a trend or a comparison across many items. Use a Chart.
- For content that is still loading. Use Skeleton or Spinner.
- For steps in a flow. Show position with a segmented Progress or a "Step 2 of 4" caption.
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".
Arc gauge
sweep={270} or sweep={180} opens the ring into a gauge. The opening is
always centred at the bottom, and minLabel / maxLabel sit under the two
terminals. A 180 gauge crops its box to the upper half, and an inside value
settles at the bottom of the arc.
The sweep runs clockwise in every locale, RTL included — gauges and clocks
don't mirror, so the minimum stays on the left terminal. Only the label text
follows dir.
Gauge
One value against a known range, read by its band. zoneStyle="current" draws
only the band the value is in: the ring and a faint track take its colour, and
its name sits under the number. Press the buttons and the figure counts on the
ring's own curve; the colour and the band's name change as the count crosses a
boundary, not when it lands. fillOnView fills the ring once, when half of it
is on screen. At the ends of the range each button stays focusable and reports
itself unavailable, so a keyboard user is never sent back to the page.
Gauge with zones
A fill over zones is a ring: the zones' own colours, dimmed, are the track,
and the ring is lit up to the value in those same colours, blending from one
zone into the next and rounding off where the value is. It grows on the count.
A thin seam marks each boundary, so no zone rests on colour alone. With no
intent or thresholds, the meter takes the intent of the zone the value sits
in; on a ring the zone's name sits under the number, and a half dial — with no
room under its value — names it with the status glyph. indicator="marker"
lights every zone at full colour instead and reads the value with a dot.
Lit dial
indicator="marker" over zones lights the whole scale at full colour, blending
from one zone into the next without a break, and a dot cut into the arc — the
dial's own colour where it stands, with the arc broken clean on either side —
reads the value. On a 270° ring at lg or xl
the icon sits in the ring's opening, in the current band's colour. The band's
name under the number says the state in words.
Segmented zones
zoneStyle="segmented" draws the scale as a tick dial: one tick every 6° of the
sweep, each in the colour of the zone it falls in. Ticks light up to the value —
one by one as the figure counts — and the rest stay on the track, so the lit
count is the reading and the change of colour is the boundary.
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.
Disk usage
A MeterGroup reads as one shape cut into parts: a 1px seam of the ground
separates neighbouring segments. remainingPosition="track" prints what is left
in the empty part of the bar instead of the legend, totalText turns the
header's fraction into a sentence, and legendValues={false} leaves the legend
as names with their swatches.
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. Describe the current
value and both endpoints; a screen reader reads the visible labels.
Marker
indicator="marker" shows the value with an indicator that sits at it,
instead of a fill that stops at it. It is the line between a
standard gauge and a capacity gauge. Use a marker when the position matters more
than how much is used: a temperature, a score, a reading on a scale.
The marker is foreground ink on a background halo, not an intent colour, so it
stands out against the bare track and every zone colour. It travels the track
minus its own width, so it stays inside the bar at min and max.
Colour zones
zones paints qualitative regions along the scale: under, healthy, over.
Each zone's from defaults to the previous zone's to, and the last zone runs
to max. A tick marks every boundary and label captions a zone, so the
bands are never told apart by colour alone.
Zones sit under a fill too. There they are a 30% wash and the fill takes the zone
the value reached. Under indicator="marker" the zones are the whole scale and
the marker reads it, so they paint at full strength.
zones | thresholds | |
|---|---|---|
| What changes | Static regions painted along the whole track | The fill's colour, by the current value |
| Reads as | "Where does the value fall?" | "What state is the value in?" |
| Together | thresholds (or an explicit intent) decide the meter's intent; zones stay painted | — |
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
MeterSkeleton takes the same type, size and sweep, so a loading gauge
has the same box as the gauge it stands in for.
On color
Inside a Surface, Meter and MeterGroup inherit tone="on-color": the track,
ticks, marker and text derive from the plate's ink, and an intent paints the
plate's own intent ink rather than the page colour. On the primary, foreground
and warning plates the intents keep their hue; on the error, info and success
plates no hue is legible, so every intent takes the plate's ink — the status
glyph and the value text still say which state the meter is in, and MeterGroup
separates its segments with a seam of the plate and gives each one a texture —
solid, two hatches, dots — that its legend swatch repeats. A linear inside value moves
to the header row on a plate, as Progress's does. Pass tone="on-color"
yourself only on a plate something else paints; an explicit tone always wins.
RTL
Under dir="rtl" a linear meter mirrors, so the fill, marker and scale start at
the right. A circular gauge does not: it turns clockwise in every locale, and
its terminal labels stay put. Leave dir unset and the meter follows the page.
API Reference
Meter props
| 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 paints a *-light trail and ends it with a foreground edge, which carries the value |
tone | "default" | "on-color" | inherited | Set on a coloured or dark plate — track, marker and text derive from the plate's ink, and an intent paints the plate's own intent ink. Inherited from a surrounding Surface; an explicit value wins |
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, on a marker track, and on a linear meter under tone="on-color"; with segments it sits in the last filled cell |
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. 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) |
sweep | 360 | 270 | 180 | 360 | How much of the circle a type="circular" meter draws. 270 and 180 are open gauges with minLabel / maxLabel under the terminals. Always clockwise |
indicator | "fill" | "marker" | "fill" | A fill that stops at the value, or a marker that sits at it. Ignored under segments |
zones | Array<MeterZone> | — | Qualitative regions painted along the scale, with a tick at every boundary. Ignored under segments |
zoneStyle | "continuous" | "segmented" | "current" | "continuous" | How zones draw on a ring: one dim track with the reached part lit in the active zone's colour and a ring on its head; a tick dial — a tick every 6° in its zone's colour, lit up to the value; or only the current zone — the ring and a faint track in its colour, its label under the number |
fillOnView | boolean | false | type="circular": the ring rests empty until half of it is on screen, then fills once while the figure counts. aria-valuenow holds the value throughout; reduced motion lands on it at once |
children | ReactNode | — | Custom content centred in the ring (type="circular") |
dir | "ltr" | "rtl" | inherited | Text direction |
className | string | — | Additional classes on the root |
MeterZone
| Field | Type | Description |
|---|---|---|
intent | MeterIntent | The zone's colour, painted as a 30% wash so the marker or fill reads above it |
from | number | Where the zone starts, on the min–max scale. Defaults to the previous zone's to (the first zone starts at min) |
to | number | Where the zone ends. Defaults to max |
label | ReactNode | Caption centred under the zone (linear) |
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="solid" / tone="soft" is the old name for variant — §1 gives tone
to default | on-color, and the two value sets never overlap, so one prop
carries both until the alias goes. 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" },
]}
/>MeterGroup props
| 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 |
tone | "default" | "on-color" | inherited | On a plate each segment paints its intent's plate ink, a seam of the plate separates neighbours, and each segment carries a texture its legend swatch repeats. Inherited from a surrounding Surface; an explicit value wins |
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 |
totalText | (used, max) => ReactNode | — | The header's reading as a sentence — `${used} of ${max} used` — from the formatted total and maximum. Needs showTotal |
showRemaining | boolean | false | Show the unfilled part of the track as "remaining". Ignored when max="sum" |
remainingLabel | ReactNode | "Remaining" | Label for the legend entry |
remainingPosition | "legend" | "track" | "legend" | Where the remainder is printed: a legend entry, or its number centred in the empty part of the bar (and no legend entry) |
legendValues | boolean | true | Print each segment's value in the legend. false leaves names and swatches |
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"). A soft segment ends in a hairline edge and its legend dot is outlined |
tone | "solid" | "soft" | Deprecated alias of variant. The group's surface tone is MeterGroup's own tone |
icon | ReactNode | Optional icon next to the legend label |
Motion
- One spring drives the fill and the figure. The fill, ring, needle and marker run on the panel curve (a critically damped spring, no overshoot, about half a second), and the visible number counts on that same curve, so it never reads ahead of or behind the bar.
aria-valuenowandaria-valuetextalways hold the real value; only what is drawn counts. - A changing label crosses over. When a text
labelchanges with the reading ("Storage" to "Storage almost full"), the new text fades in as the old fades out in the same line; the outgoing copy is hidden from assistive tech and is gone once its fade has run. Alabelthat is a node is left as given. - The band is named in words. On a ring (270° or full) at
xlwithvaluePosition="inside", the active zone's textlabelshows under the number in the zone's own ink and crosses over when the value moves into another zone. A half gauge has no room under its centre line; it keeps the zone ticks and the label row. - The ring changes band as the count crosses. The fill's colour and the band's name follow the counted figure, so they switch at the boundary on the way, not when the count lands. What assistive tech reads changes at once.
- Fill on view. With
fillOnViewthe ring rests empty until half of it is on screen, then fills once on the same curve. It happens once per mount; later values simply spring to their place. - A changing detail crosses over. A text
description("14 of 20 checks passing") fades across like the label. - Full is not "done". Unlike a progress bar, a meter does not turn to success at its maximum: it reads against a limit, so full can mean exhausted. Colour stays with
intent,thresholdsandzones. - Reduced motion: the fill and the figure jump to the new value, the label and detail swap at once, and a
fillOnViewring shows its value from the start.
Notes
- Inside value — only
lgandxllinear sizes render an inline value; below that, and on a marker track, it falls back torightrather than dropping the value, and so does a linear meter on a coloured plate, bar or cells, where the fill is the plate's ink. Withsegmentsit sits in the last filled cell (the first cell when none is), and only that cell grows if the value would not fit. The fill never gets narrower than its own value, so at low values it runs a little ahead of the true extent rather than clipping the number; at exactly0no fill is painted and the value sits on the track in the foreground colour. - RTL — a linear meter fills from the right. The track uses logical positioning (
inset-inline-start), so the fill, marks and their captions, zones, the marker and discrete cells all start at the inline start with no transform flip, and the inside value reads normally. A circular meter or arc gauge is not mirrored: circular indicators turn clockwise in every locale, and an arc gauge's terminal labels stay on their terminals. 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.
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.
Accessibility
Pattern: WAI-ARIA Meter.
- One
role="meter"per reading.Meteris built on@base-ui/react/meterand rendersrole="meter"witharia-valuemin/aria-valuemax/aria-valuenow(clamped into the range) /aria-valuetext.MeterGroupputs the role on the whole bar, never on a segment, and takes its name fromlabel. - A meter needs a name. The visible
labelsupplies it; withlabelPosition="none"or no label, passaria-label. An unnamed meter warns in development. - Locale-aware
aria-valuetext— it carries 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 (development warns when you don't). - A meter is not focusable and carries no keyboard interaction, no
aria-liveand no interactive children — a meter is a static reading, and a control inside one is unreachable. For work in flight use Progress. - 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. - 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 and the other headless libraries all make the same deviation.
Keyboard
| Key | Behaviour |
|---|---|
| — | None. A meter is not in the tab order and handles no keys |
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" trail, any intent | 1.2:1 | 1.0:1 |
variant="soft" leading edge | 15.7:1 | 14.5: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). - In
variant="soft", the edge carries the value, not the tint. The*-lighttrail cannot reach 3:1 in any intent or theme, so a soft meter ends its fill with a solid foreground edge. At the minimum value there is no fill and no edge, so an empty soft meter still relies on its value text.
Retuning the shared tokens is a system-level decision, tracked separately.
Zones and markers
Zones are a 30% wash of their intent colour, and they are told apart by their boundary ticks and captions, not by their tints. The marker is foreground ink rather than an intent colour for the same reason. Measured on the live gauge and bar, both themes, as the lowest pair across every zone:
| Pair | Light | Dark |
|---|---|---|
| Marker vs track | 15.7:1 | 14.5:1 |
| Marker vs any zone | 9.3:1 | 7.1:1 |
| Boundary tick vs the zone under it | 4.0:1 | 3.7:1 |
| Zone wash vs track | 1.2:1 | 1.3:1 |
| Solid fill vs the zones it covers | 1.0:1 | 4.4:1 |
A zone's wash does not clear 3:1 against the bare track, and nothing tuned
within a wash can. That is why every boundary carries a tick. A fill over zones
is worse: it can match its own zone's wash almost exactly. The tick, the value
text and the automatic status glyph still carry the state, but if position is
what matters, use indicator="marker".
On a coloured plate
With tone="on-color" every pair derives from the plate's own ink, so the
floor is set by the plate: the error, info and success plates carry the least
ink contrast. Measured by computed colour on the six Surface plates, both
themes, as the lowest pair across plates and intents:
| Pair | Light | Dark |
|---|---|---|
| Label, value, description, glyph vs plate | 4.8:1 | 5.4:1 |
| Solid fill or MeterGroup segment vs track | 3.6:1 | 3.9:1 |
variant="soft" leading edge vs track | 3.7:1 | 4.0:1 |
| Marker or boundary tick vs its halo | 4.8:1 | 5.4:1 |
| Track vs plate | 1.2:1 | 1.4:1 |
On the error, info and success plates no intent hue is legible, so every
intent takes the plate's ink. There the status glyph and value text name the
state, and each boundary tick sits on a halo of the plate because it is the
only thing that separates one zone from the next. In a MeterGroup a seam of
the plate separates the segments, and each segment's texture is repeated on its
legend swatch, so an entry names its segment without colour. The track is a
20% wash of the plate's ink — as faint against the plate as the page track is
against the page — and every fill clears 3:1 against it.
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Passed 2026-09-14. Each meter reads its value against the range, then its name and role: "68 of 100, Storage, level indicator", "9 of 10, Seats used, level indicator". Transcript: tests/qa/vo-transcripts/meter.md |
| VoiceOver + Chrome (macOS) | UNTESTED |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
NVDA and JAWS stay untested until a Windows host exists (QA flow §5.6).