Accessibility pattern · Feedback and status
Meter
A meter shows where a measurement sits in a known range: storage used, password strength, battery left. It is not a progress bar, which tracks a task moving towards done. The native element colors itself from its low, high and optimum values; the same state is written beside it in words, and aria-valuetext gives the number its unit.
- WCAG criteria
- 6
- Keyboard rules
- 5
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
Backup
Your phone backs up every night on Wi-Fi.
Almost full
Fair
Good
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab | Skips the meters, which are read-only, and moves between the three sliders that set their values. |
| Arrow Right or Arrow Up | On a slider, raises the value one step; the meter, its value text and its state word follow. |
| Arrow Left or Arrow Down | On a slider, lowers the value one step. |
| Home or End | On a slider, sets the lowest or highest value. |
| Page Up or Page Down | On a slider, moves by a bigger step. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| A screen reader reaches the storage meter | Cloud storage, meter, 7.2 GB of 10 GB. Almost full |
| It reaches the battery, drawn with role=meter | Phone battery, meter, 64%. Good |
| Focus reaches a slider | Set storage used, slider, 7.2 GB |
| A slider takes storage below the high mark | Cloud storage: Filling up |
| The password slider reaches the top | Password strength: Strong |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<section class="ap-meter" data-ap-meter aria-labelledby="meter-name">
<div class="ap-meter__top">
<h3 class="ap-meter__name" id="meter-name">Backup</h3>
<p class="ap-meter__sub">Your phone backs up every night on Wi-Fi.</p>
</div>
<div class="ap-meter__row" data-ap-row="storage" data-state="poor">
<div class="ap-meter__line">
<label class="ap-meter__label" for="meter-storage">Cloud storage</label>
<span class="ap-meter__value" aria-hidden="true"><span data-ap-shown>7.2 GB</span> <span class="ap-meter__of">of 10 GB</span></span>
</div>
<meter class="ap-meter__bar" id="meter-storage" min="0" max="10" low="5" high="7" optimum="0" value="7.2" aria-valuetext="7.2 GB of 10 GB" aria-describedby="meter-storage-state">7.2 GB of 10 GB</meter>
<p class="ap-meter__state" id="meter-storage-state">
<svg class="ap-meter__icon ap-meter__icon--good" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.8 2.8L16.2 9.6"/></svg>
<svg class="ap-meter__icon ap-meter__icon--fair" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>
<svg class="ap-meter__icon ap-meter__icon--poor" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M10.3 4.1 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0Z"/><path d="M12 9.5v4"/><path d="M12 17h.01"/></svg>
<span data-ap-word>Almost full</span>
</p>
</div>
<div class="ap-meter__row" data-ap-row="password" data-state="fair">
<div class="ap-meter__line">
<label class="ap-meter__label" for="meter-password">Password strength</label>
<span class="ap-meter__value" aria-hidden="true"><span data-ap-shown>2</span> <span class="ap-meter__of">of 4</span></span>
</div>
<meter class="ap-meter__bar" id="meter-password" min="0" max="4" low="1.5" high="2.5" optimum="4" value="2" aria-valuetext="Fair">Fair</meter>
<p class="ap-meter__state">
<svg class="ap-meter__icon ap-meter__icon--good" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.8 2.8L16.2 9.6"/></svg>
<svg class="ap-meter__icon ap-meter__icon--fair" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>
<svg class="ap-meter__icon ap-meter__icon--poor" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M10.3 4.1 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0Z"/><path d="M12 9.5v4"/><path d="M12 17h.01"/></svg>
<span data-ap-word>Fair</span>
</p>
</div>
<div class="ap-meter__row" data-ap-row="battery" data-state="good">
<div class="ap-meter__line">
<span class="ap-meter__label" id="meter-battery-label">Phone battery</span>
<span class="ap-meter__value" aria-hidden="true"><span data-ap-shown>64%</span></span>
</div>
<div class="ap-meter__battery" role="meter" aria-labelledby="meter-battery-label" aria-valuemin="0" aria-valuemax="100" aria-valuenow="64" aria-valuetext="64%" aria-describedby="meter-battery-state" data-low="20" data-high="40" data-optimum="100" data-state="good">
<span class="ap-meter__cells"><span class="ap-meter__fill"></span></span>
<span class="ap-meter__nub"></span>
</div>
<p class="ap-meter__state" id="meter-battery-state">
<svg class="ap-meter__icon ap-meter__icon--good" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8 12.5 2.8 2.8L16.2 9.6"/></svg>
<svg class="ap-meter__icon ap-meter__icon--fair" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M8 12h8"/></svg>
<svg class="ap-meter__icon ap-meter__icon--poor" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M10.3 4.1 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.1a2 2 0 0 0-3.4 0Z"/><path d="M12 9.5v4"/><path d="M12 17h.01"/></svg>
<span data-ap-word>Good</span>
</p>
</div>
<fieldset class="ap-meter__try">
<legend class="ap-meter__legend">Try other values</legend>
<div class="ap-meter__slider">
<label for="meter-try-storage">Set storage used</label>
<input type="range" id="meter-try-storage" min="0" max="10" step="0.1" value="7.2" aria-valuetext="7.2 GB" data-ap-drive="storage" />
</div>
<div class="ap-meter__slider">
<label for="meter-try-password">Set password strength</label>
<input type="range" id="meter-try-password" min="0" max="4" step="1" value="2" aria-valuetext="Fair" data-ap-drive="password" />
</div>
<div class="ap-meter__slider">
<label for="meter-try-battery">Set battery level</label>
<input type="range" id="meter-try-battery" min="0" max="100" step="1" value="64" aria-valuetext="64%" data-ap-drive="battery" />
</div>
</fieldset>
<p class="ap-meter__vh" role="status" data-ap-live></p>
</section>
/* Meter. Uses the --ap-* design tokens. */
.ap-meter {
width: min(100%, 520px);
margin-inline: auto;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
color: var(--ap-text);
box-shadow: var(--ap-shadow-md);
container-type: inline-size;
}
.ap-meter__top {
padding: 22px 24px 6px;
}
.ap-meter__name {
margin: 0;
font-size: 1.125rem;
font-weight: 650;
line-height: 1.3;
}
.ap-meter__sub {
margin: 4px 0 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
/* One reading: label and value, the bar, then the state in words. */
.ap-meter__row {
display: grid;
gap: 9px;
padding: 16px 24px;
}
.ap-meter__row + .ap-meter__row {
border-top: 1px solid var(--ap-border);
}
.ap-meter__line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 2px 12px;
}
.ap-meter__label {
font-weight: 600;
line-height: 1.4;
}
.ap-meter__value {
font-weight: 650;
font-variant-numeric: tabular-nums;
line-height: 1.4;
}
.ap-meter__of {
color: var(--ap-text-3);
font-weight: 500;
}
/* The native bar. The browser picks the pseudo-element from low, high and
optimum; each engine's pseudo-elements sit in rules of their own. */
.ap-meter__bar {
appearance: none;
display: block;
width: 100%;
height: 10px;
margin: 0;
border: 0;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-3);
overflow: hidden;
}
.ap-meter__bar::-webkit-meter-bar {
height: 10px;
border: 0;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-3);
}
.ap-meter__bar::-webkit-meter-optimum-value {
border-radius: var(--ap-radius-full);
background: var(--ap-success);
transition: inline-size var(--ap-duration) var(--ap-ease);
}
.ap-meter__bar::-webkit-meter-suboptimum-value {
border-radius: var(--ap-radius-full);
background: var(--ap-warning);
transition: inline-size var(--ap-duration) var(--ap-ease);
}
.ap-meter__bar::-webkit-meter-even-less-good-value {
border-radius: var(--ap-radius-full);
background: var(--ap-danger);
transition: inline-size var(--ap-duration) var(--ap-ease);
}
.ap-meter__bar::-moz-meter-bar {
border-radius: var(--ap-radius-full);
background: var(--ap-success);
}
.ap-meter__bar:-moz-meter-sub-optimum::-moz-meter-bar {
background: var(--ap-warning);
}
.ap-meter__bar:-moz-meter-sub-sub-optimum::-moz-meter-bar {
background: var(--ap-danger);
}
/* The battery: role=meter, drawn as an outline with a terminal. */
.ap-meter__battery {
display: flex;
align-items: center;
height: 22px;
}
.ap-meter__cells {
flex: 1;
height: 100%;
padding: 3px;
border: 1.5px solid var(--ap-border-strong);
border-radius: 7px;
}
.ap-meter__fill {
display: block;
width: var(--level, 0%);
height: 100%;
border-radius: 4px;
background: var(--ap-success);
transition: width var(--ap-duration) var(--ap-ease);
}
.ap-meter__nub {
width: 4px;
height: 10px;
margin-left: 2px;
border-radius: 0 3px 3px 0;
background: var(--ap-border-strong);
}
.ap-meter__battery[data-state="fair"] .ap-meter__fill {
background: var(--ap-warning);
}
.ap-meter__battery[data-state="poor"] .ap-meter__fill {
background: var(--ap-danger);
}
/* The state in words, with an icon whose shape differs per state. */
.ap-meter__state {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--ap-text-2);
font-size: .875rem;
font-weight: 600;
line-height: 1.4;
}
.ap-meter__icon {
display: none;
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-meter__row[data-state="good"] .ap-meter__state {
color: var(--ap-success);
}
.ap-meter__row[data-state="fair"] .ap-meter__state {
color: var(--ap-warning);
}
.ap-meter__row[data-state="poor"] .ap-meter__state {
color: var(--ap-danger);
}
.ap-meter__row[data-state="good"] .ap-meter__icon--good,
.ap-meter__row[data-state="fair"] .ap-meter__icon--fair,
.ap-meter__row[data-state="poor"] .ap-meter__icon--poor {
display: block;
}
/* Demo controls: sliders that set each reading. */
.ap-meter__try {
display: grid;
gap: 12px;
min-width: 0;
margin: 4px 0 0;
padding: 16px 24px 20px;
border: 0;
border-top: 1px solid var(--ap-border);
border-radius: 0 0 var(--ap-radius-lg) var(--ap-radius-lg);
background: var(--ap-surface-2);
}
.ap-meter__legend {
float: left;
width: 100%;
margin: 0 0 2px;
padding: 0;
color: var(--ap-text-3);
font-size: .75rem;
font-weight: 650;
letter-spacing: .06em;
text-transform: uppercase;
}
.ap-meter__legend + * {
clear: both;
}
.ap-meter__slider {
display: grid;
grid-template-columns: 11.5rem minmax(0, 1fr);
align-items: center;
gap: 4px 14px;
}
.ap-meter__slider label {
color: var(--ap-text-2);
font-size: .875rem;
font-weight: 500;
line-height: 1.4;
}
.ap-meter__slider input {
width: 100%;
min-height: 24px;
margin: 0;
accent-color: var(--ap-accent);
cursor: pointer;
}
.ap-meter__slider input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
border-radius: 4px;
}
.ap-meter__vh {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@container (max-width: 440px) {
.ap-meter__top { padding-inline: 18px; }
.ap-meter__row { padding-inline: 18px; }
.ap-meter__try { padding-inline: 18px; }
.ap-meter__slider { grid-template-columns: minmax(0, 1fr); }
}
/* High-contrast mode drops background colors; keep the fills visible. */
@media (forced-colors: active) {
.ap-meter__bar {
forced-color-adjust: none;
border: 1px solid CanvasText;
background: Canvas;
}
.ap-meter__bar::-webkit-meter-bar {
background: Canvas;
}
.ap-meter__bar::-webkit-meter-optimum-value {
background: Highlight;
}
.ap-meter__bar::-webkit-meter-suboptimum-value {
background: Highlight;
}
.ap-meter__bar::-webkit-meter-even-less-good-value {
background: Highlight;
}
.ap-meter__bar::-moz-meter-bar {
background: Highlight;
}
.ap-meter__fill {
forced-color-adjust: none;
background: Highlight;
}
}
@media (prefers-reduced-motion: reduce) {
.ap-meter__fill { transition: none; }
.ap-meter__bar::-webkit-meter-optimum-value { transition: none; }
.ap-meter__bar::-webkit-meter-suboptimum-value { transition: none; }
.ap-meter__bar::-webkit-meter-even-less-good-value { transition: none; }
}
/**
* Meter: native meter elements plus a role=meter fallback, each with its
* state in words beside it and its unit in aria-valuetext.
*
* Markup: [data-ap-meter] holding rows [data-ap-row] with a meter (native, or
* an element with role=meter and data-low / data-high / data-optimum), a
* [data-ap-shown] value, and a state paragraph with a [data-ap-word]. Range
* inputs with data-ap-drive="<row>" set a row's value, and a role=status
* paragraph announces a change of state.
*/
// What each row says. "words" is by state, or by value when it is a list.
const ROWS = {
storage: {
label: "Cloud storage",
shown: (v) => `${v.toFixed(1)} GB`,
valueText: (v) => `${v.toFixed(1)} GB of 10 GB`,
sliderText: (v) => `${v.toFixed(1)} GB`,
words: { good: "Plenty of room", fair: "Filling up", poor: "Almost full" },
},
password: {
label: "Password strength",
shown: (v) => String(v),
words: ["Very weak", "Weak", "Fair", "Good", "Strong"],
},
battery: {
label: "Phone battery",
shown: (v) => `${v}%`,
valueText: (v) => `${v}%`,
sliderText: (v) => `${v}%`,
words: { good: "Good", fair: "Getting low", poor: "Low, backups paused" },
},
};
// The HTML meter's own rule for its regions, so the word always matches the color.
function region(value, low, high, optimum) {
if (optimum < low) return value <= low ? "good" : value <= high ? "fair" : "poor";
if (optimum > high) return value >= high ? "good" : value >= low ? "fair" : "poor";
return value >= low && value <= high ? "good" : "fair";
}
function bounds(meter) {
const n = (name) => Number(meter.getAttribute(name) ?? meter.dataset[name]);
return meter.tagName === "METER"
? { low: meter.low, high: meter.high, optimum: meter.optimum }
: { low: n("low"), high: n("high"), optimum: n("optimum") };
}
export function init(root) {
const live = root.querySelector("[data-ap-live]");
let timer = 0;
function set(key, value, { announce = true } = {}) {
const spec = ROWS[key];
const row = root.querySelector(`[data-ap-row="${key}"]`);
if (!spec || !row) return;
const meter = row.querySelector("meter, [role=meter]");
const { low, high, optimum } = bounds(meter);
const state = region(value, low, high, optimum);
const word = Array.isArray(spec.words) ? spec.words[Math.round(value)] : spec.words[state];
const valueText = spec.valueText ? spec.valueText(value) : word;
if (meter.tagName === "METER") meter.value = value;
else {
meter.setAttribute("aria-valuenow", String(value));
meter.style.setProperty("--level", `${value}%`);
}
meter.setAttribute("aria-valuetext", valueText);
meter.dataset.state = state;
row.dataset.state = state;
row.querySelector("[data-ap-shown]").textContent = spec.shown(value);
const wordEl = row.querySelector("[data-ap-word]");
const changed = wordEl.textContent !== word;
wordEl.textContent = word;
// Announce a new state once the slider settles, not every step on the way.
if (announce && changed) {
clearTimeout(timer);
timer = setTimeout(() => {
live.textContent = `${spec.label}: ${word}`;
}, 450);
}
}
function onInput(event) {
const input = event.target.closest("[data-ap-drive]");
if (!input) return;
const key = input.dataset.apDrive;
const value = Number(input.value);
const spec = ROWS[key];
input.setAttribute("aria-valuetext", spec.sliderText ? spec.sliderText(value) : spec.words[Math.round(value)]);
set(key, value);
}
// Paint every row from its meter's starting value, without announcing.
for (const row of root.querySelectorAll("[data-ap-row]")) {
const meter = row.querySelector("meter, [role=meter]");
const value = meter.tagName === "METER" ? meter.value : Number(meter.getAttribute("aria-valuenow"));
set(row.dataset.apRow, value, { announce: false });
}
root.addEventListener("input", onInput);
return () => {
clearTimeout(timer);
root.removeEventListener("input", onInput);
};
}
for (const root of document.querySelectorAll("[data-ap-meter]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.4.3 Contrast (Minimum)
Level AA
The state words use the success, warning and danger text colors, which clear 4.5:1 on the card in light and dark.
-
1.3.1 Info and Relationships
Level A
The native meter and the role=meter battery expose their minimum, maximum and current value, and each is named by its visible label.
-
1.4.1 Use of Color
Level A
Green, amber and red are never the only signal: every row says Almost full, Fair or Good in words, with a different icon for each state.
-
1.4.11 Non-text Contrast
Level AA
The fill clears 3:1 against the track and the card in both themes, and the battery outline uses the control-edge token.
-
4.1.2 Name, Role, Value
Level A
role=meter with aria-valuenow, aria-valuemin and aria-valuemax gives the drawn battery the same name, role and value as the native element.
-
4.1.3 Status Messages
Level AA
When a value crosses into a new state, a polite status message says so once, without moving focus or repeating every step.
Usage
When to use it
Use it
- A measurement within a known range: disk or plan usage, battery, signal, a password's strength, a budget spent.
- Values where ranges matter, so low, high and optimum can say whether the reading is good or bad.
Use something else
- How far a task has got, like an upload or an install: that is a progress bar.
- A value people set themselves: that is a slider.
- A number with no meaningful maximum, like followers or page views: show the number.
Common failures
How it usually goes wrong
A progress bar used as a gauge
role=progressbar tells people something is loading and will finish. Storage at 72% is not on its way to 100; it is a reading, so it is a meter.
The state shown only by color
A bar that turns red is invisible as a warning to many people with color blindness and to anyone in high-contrast mode. The word Almost full says it for everyone.
A bare number with no unit
A meter with value 7.2 is announced as "7.2", or as a percentage the browser works out. aria-valuetext="7.2 GB of 10 GB" says what the number means.
A drawn bar with no role
A div with a colored width is just decoration to assistive technology. role=meter and its value attributes, or the native element, make it a reading.
Every change announced
A live region that reads out each new value while a slider moves floods the speech queue. Announcing only the change of state keeps it useful.
Notes
Building it
- Set low, high and optimum and the browser picks the bar's color: optimum below low means lower is better (storage), above high means higher is better (battery). The script works the state out the same way, so the word and the color always agree.
- aria-valuetext replaces the number for screen readers, so put the unit in it and keep it short. Link the state word with aria-describedby, unless the value text already is that word, as with the password.
- Use role=meter only when the native element cannot be drawn the way you need, like the battery here, and give it aria-valuenow, aria-valuemin, aria-valuemax and a name.
- Meters are read-only and take no tab stop. The sliders under them are demo controls for trying values; in a real product the value comes from your data.
- Style the native bar with ::-webkit-meter-* and ::-moz-meter-bar in separate rules: one unknown pseudo-element in a selector list drops the whole rule.
Sources: HTML: the meter element · WAI-ARIA Authoring Practices: Meter
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction