A scalar measurement within a known range (WAI-ARIA meter): storage used, a password's strength, a battery. Output-only and directive-free like Progress, which documents the shared parts, the formatting and the Server Component story. What Meter adds is the platform's regions: low, high and optimum decide whether the value is good, not so good, or bad.
// No "use client": a Server Component, and Meter.Root runs on the server with it. Lower is better
// (optimum 0): up to `low` the region is "optimum", up to `high` "suboptimum", above it
// "even-less-good". The track draws the zones from --meter-low / --meter-high, the fill is
// coloured by data-region, and getAriaValueText adds the words the colour carries. Function props
// go to the Root only: the parts are client components, and a Server Component cannot pass them
// a function.
import { Meter } from "@funstack/ui/meter";
const quota = 5;
const accounts = [
{ id: "personal", name: "Personal", used: 1.2 },
{ id: "team", name: "Team", used: 4.1 },
{ id: "archive", name: "Archive", used: 4.6 },
];
export function MeterStorage() {
return (
<div className="demo-stack">
{accounts.map((account) => (
<Meter.Root
key={account.id}
className="demo-meter"
value={account.used}
max={quota}
low={quota * 0.7}
high={quota * 0.9}
optimum={0}
locale="en-US"
format={{ style: "unit", unit: "gigabyte", maximumFractionDigits: 1 }}
getAriaValueText={(_, { formattedValue, region }) =>
region === "even-less-good" ? `${formattedValue}, almost full` : formattedValue
}
>
{/* The Root's children(state) runs on the server with the Root. A function passed to a
part (Meter.Value children) would not: the parts are client components. */}
{({ formattedValue, max }) => (
<>
<div className="demo-meter-head">
<Meter.Label>{account.name} storage</Meter.Label>
<Meter.Value>{`${formattedValue} of ${max} GB`}</Meter.Value>
</div>
<Meter.Track className="demo-meter-track">
<Meter.Indicator className="demo-meter-fill" />
</Meter.Track>
</>
)}
</Meter.Root>
))}
</div>
);
}/* Meter: the Root publishes --meter-value, --meter-low, --meter-high and --meter-optimum (the
clamped boundaries as percentages, inherited); every part carries data-region. */
.demo-meter {
display: grid;
gap: 0.35rem;
inline-size: min(24rem, 100%);
}
.demo-meter-head {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--color-text);
font-size: 0.9rem;
}
.demo-meter-head [aria-hidden] {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* The zones, drawn from the boundaries. */
.demo-meter-track {
position: relative;
block-size: 8px;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(
to right,
#d3f9d8 var(--meter-low),
#fff3bf var(--meter-low) var(--meter-high),
#ffe3e3 var(--meter-high)
);
}
.demo-meter-plain {
background: var(--color-border);
}
/* The fill, coloured by region. */
.demo-meter-fill {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
inline-size: var(--meter-value);
border-radius: inherit;
transition: inline-size 150ms ease-out;
}
.demo-meter-fill[data-region="optimum"] {
background: #2f9e44;
}
.demo-meter-fill[data-region="suboptimum"] {
background: #f08c00;
}
.demo-meter-fill[data-region="even-less-good"] {
background: #e03131;
}
@media (prefers-reduced-motion: reduce) {
.demo-meter-fill {
transition: none;
}
}This example is a Server Component (no "use client"), and so is Meter.Root: the region, the text and the getAriaValueText function all ran on the server.
import { Meter } from "@funstack/ui/meter";
<Meter.Root value={used} max={quota} low={quota * 0.7} high={quota * 0.9} optimum={0}
format={{ style: "unit", unit: "gigabyte", maximumFractionDigits: 1 }}>
<Meter.Label>Storage</Meter.Label>
<Meter.Value>{({ formattedValue, max }) => `${formattedValue} of ${max} GB`}</Meter.Value>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>value is required: a meter is never indeterminate. A non-finite value warns in development and renders as min. The parts are the same as Progress's (div rail, span fill, span[id] label, span[aria-hidden] value), with data-region instead of data-indeterminate / data-complete. From a Server Component, pass functions to the Root only: the parts are client components, so format a part's content in the Root's children(state), as the storage example does (see Progress, Server Components).
HTML's defaults and clamping, in order: low (default min) is clamped to [min, max], high (default max) to [low, max], optimum (default the midpoint) to [min, max]. With the defaults every value is optimum.
| Where optimum is | value | data-region |
|---|---|---|
optimum < low (lower is better) | value <= low | optimum |
low < value <= high | suboptimum | |
high < value | even-less-good | |
high < optimum (higher is better) | high <= value | optimum |
low <= value < high | suboptimum | |
value < low | even-less-good | |
low <= optimum <= high (the middle is best) | low <= value <= high | optimum |
| otherwise | suboptimum (never even-less-good) |
The names and the boundaries are Chromium's (::-webkit-meter-optimum-value and friends). Firefox's native <meter> differs on two boundaries: a value exactly at low when lower is better, or exactly at high when higher is better, is suboptimum there. The words are yours: nothing in the default aria-valuetext names the region, so add them with getAriaValueText as the storage example does ("4.6 GB, almost full").
A Meter is not a form control and does not register with a Field. Put it next to the control with its own Meter.Label, and describe the control with the meter's text by giving Meter.Value an id and adding it to the control's aria-describedby. A Field.Root whose only content is a Meter is not supported: use a plain element with the Meter's own label.
"use client";
import { useState } from "react";
import { Field } from "@funstack/ui/field";
import { Meter } from "@funstack/ui/meter";
const words = ["Too short", "Weak", "Fair", "Good", "Strong"];
/** A toy score from 0 to 4: length, case, digits, symbols. */
function strength(password: string): number {
if (password.length < 8) return 0;
return [/[a-z]/.test(password) && /[A-Z]/.test(password), /\d/, /[^A-Za-z0-9]/]
.map((test) => (typeof test === "boolean" ? test : test.test(password)))
.reduce((score, passed) => score + (passed ? 1 : 0), 1);
}
// A password-strength meter next to the Field's control, not registered with the Field: it has
// its own Meter.Label, and the input is described by Meter.Value (an aria-hidden element
// referenced by aria-describedby still contributes its text). Higher is better: optimum = max.
export function MeterStrength() {
const [password, setPassword] = useState("");
const score = strength(password);
return (
<Field.Root name="password" className="demo-fieldset">
<Field.Label className="demo-label">New password</Field.Label>
<Field.Control
type="password"
autoComplete="new-password"
className="demo-input"
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-describedby="password-strength"
/>
<Meter.Root
className="demo-meter"
value={score}
max={4}
low={1.5}
high={2.5}
optimum={4}
getAriaValueText={(value) => words[value] ?? null}
>
<div className="demo-meter-head">
<Meter.Label>Strength</Meter.Label>
<Meter.Value id="password-strength">{({ value }) => words[value]}</Meter.Value>
</div>
<Meter.Track className="demo-meter-track demo-meter-plain">
<Meter.Indicator className="demo-meter-fill" />
</Meter.Track>
</Meter.Root>
</Field.Root>
);
}/* Meter: the Root publishes --meter-value, --meter-low, --meter-high and --meter-optimum (the
clamped boundaries as percentages, inherited); every part carries data-region. */
.demo-meter {
display: grid;
gap: 0.35rem;
inline-size: min(24rem, 100%);
}
.demo-meter-head {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--color-text);
font-size: 0.9rem;
}
.demo-meter-head [aria-hidden] {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* The zones, drawn from the boundaries. */
.demo-meter-track {
position: relative;
block-size: 8px;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(
to right,
#d3f9d8 var(--meter-low),
#fff3bf var(--meter-low) var(--meter-high),
#ffe3e3 var(--meter-high)
);
}
.demo-meter-plain {
background: var(--color-border);
}
/* The fill, coloured by region. */
.demo-meter-fill {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
inline-size: var(--meter-value);
border-radius: inherit;
transition: inline-size 150ms ease-out;
}
.demo-meter-fill[data-region="optimum"] {
background: #2f9e44;
}
.demo-meter-fill[data-region="suboptimum"] {
background: #f08c00;
}
.demo-meter-fill[data-region="even-less-good"] {
background: #e03131;
}
@media (prefers-reduced-motion: reduce) {
.demo-meter-fill {
transition: none;
}
}Meter.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | number | – | Required. Clamped to [min, max]. |
min | number | 0 | The start of the range. |
max | number | 100 | The end of the range (the platform's default is 1). max <= min warns in development and renders 0%. |
low | number | min | Upper bound of the low part of the range. A non-finite number counts as absent (the default applies). |
high | number | max | Lower bound of the high part of the range. A non-finite number counts as absent (the default applies). |
optimum | number | (min + max) / 2 | The best value; decides which part of the range is optimum. A non-finite number counts as absent (the default applies). |
format | Intl.NumberFormatOptions | { style: "percent" } | As on Progress: the percent style formats the fraction, any other style the value. |
locale | string | the runtime's | BCP 47 tag for Intl.NumberFormat. |
getAriaValueText | (value: number, state: MeterState) => string | null | undefined | – | The aria-valuetext to render instead of formattedValue; null or undefined renders none. |
className | string | ((state: MeterState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MeterState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MeterState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MeterState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
Owned: role, aria-valuemin, aria-valuemax and aria-valuenow (always), aria-valuetext (unless getAriaValueText returns null or undefined), data-region, and aria-labelledby (your tokens, then the Label's id). Your style wins over the custom properties per property.
Meter.Track, Meter.Indicator, Meter.Label, Meter.ValueAs on Progress, with MeterState. Meter.Value shows formattedValue by default and is aria-hidden unless you pass aria-hidden={false}.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: MeterState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MeterState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MeterState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MeterState) => ReactNode) | – | Replace the default <div | span> (element form) or take over rendering (function form). |
| Field | Type | Meaning |
|---|---|---|
value | number | Clamped to [min, max]. |
min, max | number | The range. |
low, high, optimum | number | After the defaults and clamping. |
percentage | number | 0 to 100. |
formattedValue | string | The Intl.NumberFormat text. |
valueText | string | null | What aria-valuetext carries. |
region | "optimum" | "suboptimum" | "even-less-good" | See Regions. |
| Name | On | Meaning |
|---|---|---|
data-region | every part | optimum, suboptimum or even-less-good. |
--meter-value | Root (inherited) | The percentage. |
--meter-low, --meter-high, --meter-optimum | Root (inherited) | The clamped boundaries as percentages, for drawing the zones. |
Screen-reader support for the meter role is older and less even than for progressbar, so the Root always renders aria-valuenow and aria-valuetext, and a meter must always have a name (a Meter.Label or aria-label). Updates are not announced.