Meter
Indicators
Open in Storybook: Meter (opens in a new tab)
Overview
A native meter for a read-only measurement inside a known range: fuel, storage used, a battery, a score. The browser maps min, max and value to the accessibility tree, so the component adds only what the browser leaves out: the status in words and a shape, the value in words, and the design around it. The browser paints the zone and says nothing about it, so the status is worked out from low, high and optimum.
It is never focusable and never interactive. A value the user sets is a range, a task moving forward is a progress, and a rating the user gives is a radio group.
Anatomy
- Label
label forNames the meter. Always visible.
- Track
meter,::-webkit-meter-barThe range from min to max. The meter holds the role, the name, the value and the range.
- Fill
::-webkit-meter-optimum-valueand its two siblingsPainted by the browser from low, high and optimum: purple is the good zone, yellow the middle, red the worst.
- Status
p aria-hidden="true"with ansvgWhere the value is, in a word and a shape (check, dash, warning), so colour is never the only cue.
- Value text
span aria-hidden="true"The visible copy of the value. Hidden because the meter already exposes it.
- Marks
div aria-hidden="true"A custom design: squares or stars, one for each unit. The native meter sits invisible over them.
Usage
Use cases
The eight of the design, each the real component, and each works: move the slider under it to try a value and see the zone, the word and what a screen reader says.
Fuel level
Higher is better.
80 out of 100, good Says “80 out of 100, good”.
min=0 max=100 low=33 high=66 optimum=80 value=80Storage used
Lower is better.
12.4 of 16 GB used, filling up Says “12.4 of 16 GB used, filling up”.
min=0 max=16 low=8 high=13 optimum=4 value=12.4Battery
Higher is better, warn when low.
15%, low Says “15%, low”.
min=0 max=100 low=20 high=80 optimum=100 value=15Password strength
0 to 4, higher is better.
Weak Says “Weak”.
min=0 max=4 low=2 high=3 optimum=4 value=1Coffee intensity
Custom squares, no zones.
7 out of 10 Says “7 out of 10”.
min=0 max=10 value=7Product rating
Static info only.
4 out of 5 stars Says “4 out of 5 stars”.
min=0 max=5 value=4Accessibility score
Higher is better.
92 out of 100, good Says “92 out of 100, good”.
min=0 max=100 low=50 high=90 optimum=100 value=92Room humidity
Middle is best.
45%, comfortable Says “45%, comfortable”.
min=0 max=100 low=30 high=60 optimum=45 value=45
Props
| Prop | Does | Default |
|---|---|---|
label | Names the meter. Always visible | Required |
value | The measurement | Required |
min | Lowest value of the range | 0 |
max | Highest value of the range. Required: left alone the browser makes it 1 and a value of 7 reads full | Required |
low · high · optimum | Cut the range in three regions and say which one is good | The browser's: min, max, the midpoint |
look | bar, squares or stars. The marks are one for each unit, under an invisible native meter | bar |
words | What each region is called: { low, middle, high }. With them the field shows the status as a word and a shape, and the word goes into aria-valuetext | None: no status |
valueText | The value in words, (value, max) => string. Empty when the status word is the whole text | "80 out of 100" |
shown | The visible copy of the value, (value, max) => string, or null for none | "80 / 100" |
Example
words is what turns the zone into a word and a shape. The meter says "20 out of 100, low".
import { Meter } from '@/components/Meter'
<Meter
label="Fuel level"
value={20}
max={100}
low={33}
high={66}
optimum={80}
words={{ low: 'Low', middle: 'Medium', high: 'Good' }}
/>Specs
Measurements
| Part | Property | Value |
|---|---|---|
| Field | Gap | 8px · 12px (0.5rem · 0.75rem) |
| Label | Text | 13px (0.8125rem) / 1.45, 700 |
| Bar | Height · corner | 20px (1.25rem) · 6px |
| Bar | Border | 2px solid |
| Fill | Edge | 2px line on its end |
| Segment | Size · gap | 10 × 20px · 3px |
| Star | Size · gap | 20 × 20px, 1.5px line · 4px |
| Status | Text · icon | 13px (0.8125rem) / 1.4, 600 · 16px icon, 6px gap |
| Value text | Text | 12px (0.75rem) / 1.45, monospace, tabular numbers |
| Label · value text | Width from 48rem | 9rem · 11rem at the least |
Zones
low and high cut the range in three. optimum says which region is the good one, and the other two are ranked by their distance from it. The word and the icon follow the same rule as the fill, so they never disagree with it.
| Zone | Where the value is | Fill | Icon | Word |
|---|---|---|---|---|
| Best | In the optimum region | Purple | Check in a circle | Purple |
| Middle | One region from it | Yellow | Dash in a circle | Ink |
| Worst | On the far side | Red | Warning triangle | Red |
Zone by optimum
| Optimum is | Value below low | Value between | Value above high |
|---|---|---|---|
Between low and high | middle | best | middle |
Below low (lower is better) | best | middle | worst |
Above high (higher is better) | worst | middle | best |
Layout
The breakpoints are container queries in rem, so they follow the room the field has and the size of the text: a field in a narrow card stacks on a wide screen. One DOM, only the CSS changes. Each use case is a story in Storybook. Pick the width in the viewport menu of the toolbar, for any story: 640 CSS px · 200% zoom or 320 CSS px · 400% zoom.
| Breakpoint | CSS width | Reached by | Layout |
|---|---|---|---|
| ≥ 48rem | ≥ 768px | Desktop at 100–150% zoom | One row: label · bar · value text |
| 30–48rem | 480–767px | 1280px at 200% (640 CSS px) · tablets · text at 200% | Label and value text on one row, bar full width below |
| < 30rem | < 480px | 1280px at 400% (320 CSS px) · phones | Stacked: label, bar, value text. No sideways scroll |
Attributes
| Attribute | Meaning | Default |
|---|---|---|
min | Lowest value of the range | 0 |
max | Highest value of the range. Always set | 1 |
value | The measurement, kept between min and max | 0 |
low | Upper edge of the low region | min |
high | Lower edge of the high region | max |
optimum | The best value | The midpoint |
aria-valuetext | The value in words, with the status | None: the number is read |
Bar parts
| Part | WebKit · Blink | Firefox |
|---|---|---|
| Wrapper | ::-webkit-meter-inner-element | — |
| Track | ::-webkit-meter-bar | The element itself |
| Fill, good zone | ::-webkit-meter-optimum-value | :-moz-meter-optimum::-moz-meter-bar |
| Fill, middle zone | ::-webkit-meter-suboptimum-value | :-moz-meter-sub-optimum::-moz-meter-bar |
| Fill, worst zone | ::-webkit-meter-even-less-good-value | :-moz-meter-sub-sub-optimum::-moz-meter-bar |
Markup
<label for="fuel">Fuel level</label>
<meter id="fuel" min="0" max="100" low="33" high="66" optimum="80" value="20"
aria-valuetext="20 out of 100, low">20 out of 100, low</meter>
<p aria-hidden="true"><svg focusable="false">…</svg> Low · 20 / 100</p>Script
const WORDS = { low: 'Low', middle: 'Medium', high: 'Good' } // by region, never by colour
// A value on a boundary joins the optimum's region, as in the browsers
function where({ low, high, optimum, value }) {
const side = (n) => (n < low ? 'low' : n > high ? 'high' : 'middle')
const good = side(optimum)
const region =
good === 'low' ? (value <= low ? 'low' : value <= high ? 'middle' : 'high')
: good === 'high' ? (value >= high ? 'high' : value >= low ? 'middle' : 'low')
: side(value)
const zone = region === good ? 'best' : region === 'middle' || good === 'middle' ? 'middle' : 'worst'
return { region, zone }
}
function render(meter, status) { // meter.low, .high, .optimum: the browser resolves them
const { region, zone } = where(meter)
const text = `${meter.value} out of ${meter.max}, ${WORDS[region].toLowerCase()}`
meter.setAttribute('aria-valuetext', text)
meter.textContent = text
status.dataset.zone = zone // picks the icon and the colour
status.lastChild.textContent = ` ${WORDS[region]} · ${meter.value} / ${meter.max}`
}Tokens
Component tokens
| Part | Property | Token | Value |
|---|---|---|---|
| Bar | Border width | bw | 2px |
| Bar | Border and fill edge colour | ink | #16161a |
| Bar | Corner | — | 6px |
| Track | Fill | — | #e6e6ea |
| Fill, best | Fill | lilac | #5e548e |
| Fill, middle | Fill | marker | #ffe600 |
| Fill, worst | Fill | danger | #82002a |
| Segment, on | Fill | — | #5d5fef |
| Segment, off | Border · fill | — · white | #6b6b75 · #ffffff |
| Star | Filled · outline | — | #5d5fef · #6b6b75 |
| Label | Text colour | ink | #16161a |
| Value text | Text colour | ink-soft | #4b4b55 |
| Status, best | Text and icon | lilac | #5e548e |
| Status, middle | Text · icon | ink · marker | #16161a · #ffe600 |
| Status, worst | Text and icon | danger | #82002a |
Guidelines
Content
| Part | Format | Example |
|---|---|---|
| Label | What is measured | "Fuel level" · "Storage" |
| Value text | The value, then what it is of, in words | "80 out of 100" · "12.4 of 16 GB used" · "7 out of 10" |
| Status word | One for each side of the range, by what it means and not by colour | "Low · Medium · Good" · "Too dry · Comfortable · Too humid" |
| Visible value | Short, in the same unit | "80 / 100" · "12.4 / 16 GB" · "15%" |
Behavior
| Event | Result |
|---|---|
| Any change of the value | value, aria-valuetext, the text inside the meter and the status change together |
| A value on a boundary | In the optimum region when it touches it, as the browser paints it |
| A value outside the range | The browser clamps it. The marks never show more than the range |
No low, high or optimum | The browser defaults. A custom design has no zones and no status |
| Marks | One for each unit of the range, the first ones lit. 3.5 lights four; the value text keeps 3.5 |
| Status word with no number | The word is the whole value text: "Weak" |
Alternatives
| Element | Use it for | The user changes it | Role |
|---|---|---|---|
meter | A measurement inside a known range | No | meter |
progress | Completion of a task | No | progressbar |
input type="range" | Picking a value on a continuous scale | Yes | slider |
| Radio group | Picking one of a few options, a rating the user gives | Yes | radiogroup, radio |
| Plain text | A number with no known range ("1,204 followers") | No | — |
Accessibility
Keyboard
| Key | Action | Native |
|---|---|---|
| Tab | Skips the meter: it is not a tab stop | Yes |
| Arrow keys | Nothing: the value is not the user's to change | Yes |
Roles and properties
| Property | Exposed | Note |
|---|---|---|
| Role | meter | Native. Never role="meter" on a div |
| Name | "Fuel level" | From label for. aria-labelledby when the name is in several elements, aria-label only with no visible text |
| Value | "80 out of 100, good" | aria-valuetext. Without it the number alone is read. Never aria-valuenow |
| Range | min 0 · max 100 | From the native attributes |
| Fallback | "80 out of 100, good" | The text inside meter, for browsers without it |
| Hidden | Status, value text, marks | aria-hidden="true", and focusable="false" on an svg |
| Tab stops | 0 | No tabindex, onclick or role="button" |
Screen reader
A meter is static: it shows a value, not the progress of that value, and announces nothing. A changed value is read when the user reaches the meter. The exact words differ by screen reader, browser and platform.
| Moment | What is read |
|---|---|
| Reach the meter | Its role, its name, aria-valuetext and the range: "Fuel level", meter, "80 out of 100, good" |
| The value changes | Nothing. The new value is read the next time the meter is reached |
| The page loads | Nothing |
Measured behavior
Chromium 148 and 149, WebKit 26.4.
| Observation | Consequence |
|---|---|
With the optimum below low, a value equal to low is painted as the good zone. Above high, a value equal to high is | Words and icon use the same edges. A rule that tests value < low and value > high is wrong on the edges |
The accessibility tree has the meter with its name, value, min, max and aria-valuetext, and not the status, the value text, the squares or the stars | Hide every copy with aria-hidden |
A meter at opacity: 0 stays in the tree, name and value text included | Stretch it over a custom design. Never display: none or visibility: hidden |
| The yellow fill is 1.02:1 against the track | The 2px ink edge on the end of the fill shows where it stops, and the value is in words as well |
| The bar and its three zones look the same in WebKit as in Chromium | One stylesheet with both sets of pseudo-elements |
Support
The Firefox rules follow the design and have not been run.
| Browser | Bar parts | Checked |
|---|---|---|
| Chromium | ::-webkit-meter-* | Yes |
| WebKit (Safari) | ::-webkit-meter-* | Yes |
| Firefox | ::-moz-meter-bar with :-moz-meter-optimum, -sub-optimum, -sub-sub-optimum | Yes |
WCAG criteria
| Criterion | Requirement here |
|---|---|
| 1.1.1 Non-text Content (A) | The marks, the stars and the icons are decoration, aria-hidden. The meter carries the information |
| 1.3.1 Info and Relationships (A) | label for names the meter. Role, value and range are native |
| 1.4.1 Use of Color (A) | The zone is a word and a shape as well as a colour, and is in aria-valuetext |
| 1.4.3 Contrast (Minimum) (AA) | Label 18:1, value text 8.6:1, status words 6.7:1 (purple) and 10.6:1 (red) |
| 1.4.4 Resize Text (AA) | Sizes in rem, no fixed height on the field |
| 1.4.10 Reflow (AA) | Stacked under 30rem. No sideways scroll at 320 CSS px |
| 1.4.11 Non-text Contrast (AA) | Bar border and fill edge 18:1, empty marks 5.3:1, filled marks 4.8:1 |
| 1.4.12 Text Spacing (AA) | Label, status and value text wrap instead of clipping |
| 4.1.2 Name, Role, Value (A) | Native meter, label for, aria-valuetext |
Related
This section is still in progress.
References
This section is still in progress.