Meter
Show a static measurement within a known range. A filled bar reports a settled reading — disk usage, battery, a score, remaining quota — with an optional label and a value that formats for the reader's locale. Declare thresholds and the tone derives itself.
Basic
Give Meter.Root a value and an aria-label, then compose a Meter.Track holding a Meter.Indicator. The range defaults to 0–100. A Meter.Label and Meter.Value caption the reading, and labels="top" lays them out in a header row above the track — no wrapper needed. The fill sizes itself to the value.
"use client";
import { Meter } from "@stridge/noctis";
export default function MeterBasic() {
return (
<div className="w-full max-w-sm">
{/* The default range is 0–100. `labels="top"` lays the caption row out above the track — no
wrapper div — and the Value render child reads the percentage straight off the value. */}
<Meter.Root value={68} aria-label="Storage used" labels="top">
<Meter.Label>Storage used</Meter.Label>
<Meter.Value>{(_formatted, value) => `${value}%`}</Meter.Value>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
</div>
);
}
Thresholds
A meter is a reading, so its colour signals status, not progress. Declare low, high, and optimum — exactly the HTML <meter> model — and the fill tone is derived: success in the healthy zone, warning as it tightens, danger once it's critical. The position of optimum decides which direction is good, so one declaration covers a quota (high is bad), a battery (high is good), or a temperature (low is good). No hand-written traffic-light logic, and because the breakpoints live in one place the bar and any quota note can't disagree. An explicit tone always overrides the derived one.
When a tone is in play, the meter also defaults a localized aria-valuetext (e.g. "96% — critical") so assistive tech hears the status the colour shows, not just a number.
"use client";
import { Meter } from "@stridge/noctis";
// One source of truth for the breakpoints — declare them once and the fill tone is derived per reading.
// A disk quota: low usage is best, so `optimum` sits at the floor; the bar turns warning past `high`
// and danger near full. No hand-written traffic-light switch, and the status is announced for AT too.
const QUOTA = { low: 70, high: 90, optimum: 0 } as const;
const READINGS = [
{ label: "Documents", value: 42 },
{ label: "Photos", value: 84 },
{ label: "System", value: 96 },
] as const;
export default function MeterThresholds() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{READINGS.map((reading) => (
<Meter.Root key={reading.label} value={reading.value} aria-label={reading.label} labels="top" {...QUOTA}>
<Meter.Label>{reading.label}</Meter.Label>
<Meter.Value>{(_formatted, value) => `${value}%`}</Meter.Value>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
))}
</div>
);
}
Battery
The canonical accessible meter. Here a high charge is best, so optimum sits at the top of the range and the bar turns danger as it drains below low. A custom getAriaValueText gives the APG battery readout — a screen reader hears the time remaining — while the Meter.Value render child shows a qualitative reading instead of a bare number. labels="side" flanks the track on a single line.
"use client";
import { Meter } from "@stridge/noctis";
// Battery: a high charge is best, so `optimum` sits at the top of the range — the bar turns danger as it
// drains below `low`. The custom `getAriaValueText` is the canonical APG battery readout, so a screen
// reader hears the time remaining rather than a bare number, while the Value shows the same in short form.
export default function MeterBattery() {
return (
<div className="w-full max-w-sm">
<Meter.Root
value={15}
low={20}
high={80}
optimum={100}
aria-label="Battery"
labels="side"
getAriaValueText={(_formatted, value) => `${value}% — about 30 minutes remaining`}
>
<Meter.Label>Battery</Meter.Label>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
<Meter.Value>{() => "≈30 min left"}</Meter.Value>
</Meter.Root>
</div>
);
}
Custom range
Set min and max for any scale — a temperature, a price band, a rating — and the fill maps the value across it. Thresholds work in the custom range too: this CPU gauge is best when cool (optimum at the floor), so warm derives warning and hot derives danger, and the value reads in its own units.
"use client";
import { Meter } from "@stridge/noctis";
// CPU temperature in °C: cooler is better, so `optimum` sits at the floor (`optimum <= low`). The tone is
// derived across the custom range — warm crosses into warning, hot into danger — never hand-picked.
export default function MeterCustomRange() {
return (
<div className="w-full max-w-sm">
<Meter.Root
value={88}
min={32}
max={104}
low={60}
high={80}
optimum={32}
aria-label="CPU temperature"
labels="top"
format={{ style: "unit", unit: "celsius" }}
>
<Meter.Label>CPU temperature</Meter.Label>
<Meter.Value />
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
</div>
);
}
Sizes
Two track thicknesses — md (the default, a thin rail matching the Progress twin) and sm, thinner still — sharing the same ends and rhythm. Both follow the radius knob, so a RadiusScope retunes them. For an arbitrary height, pass thickness (a number is px, a string is any CSS length).
"use client";
import { Meter } from "@stridge/noctis";
// `md` is the default — a thin rail that matches the Progress twin — with `sm` thinner still.
const SIZES: Meter.Size[] = ["md", "sm"];
export default function MeterSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
{SIZES.map((size) => (
<Meter.Root key={size} value={60} size={size} aria-label={`Usage (${size})`} labels="top">
<Meter.Label>Usage</Meter.Label>
<Meter.Value>{(_formatted, value) => `${value}%`}</Meter.Value>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
))}
</div>
);
}
Labels
labels="top" (the default) stacks the caption row above the track; labels="side" flanks the track on one line. The root owns the layout — the same Meter.Label + Meter.Value + Meter.Track compose either way, and a meter with no caption collapses to just the bar.
"use client";
import { Meter } from "@stridge/noctis";
// `top` (the default) stacks the caption row above the track; `side` flanks the track on one line. The
// root owns the layout, so the same Label + Value + Track compose either way — no wrapper.
const PLACEMENTS: Meter.Labels[] = ["top", "side"];
export default function MeterLabels() {
return (
<div className="flex w-full max-w-sm flex-col gap-8">
{PLACEMENTS.map((labels) => (
<Meter.Root key={labels} value={72} aria-label={`Memory (${labels})`} labels={labels}>
<Meter.Label>Memory</Meter.Label>
<Meter.Value>{(_formatted, value) => `${value}%`}</Meter.Value>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
))}
</div>
);
}
Usage card
Composed with a Surface: a billing-style usage card where the derived tone drives the bar and a status line echoes it. One set of thresholds, so the warning note fires at the same point the bar turns.
Nearly full — upgrade to add space.
"use client";
import { Meter, Surface } from "@stridge/noctis";
// A billing-style usage card: a bordered Surface frames the meter (capped rounding under the pill default), the derived tone
// drives the bar, and a status line echoes it. The breakpoints live in one place, so the bar and the
// note can never disagree — the quota warning fires at the same threshold the bar turns.
export default function MeterUsageCard() {
return (
<Surface bordered className="flex w-full max-w-sm flex-col gap-3 p-4">
<Meter.Root value={96} low={70} high={90} optimum={0} aria-label="Storage" labels="top">
<Meter.Label>Storage</Meter.Label>
<Meter.Value>{(_formatted, value) => `${value} / 100 GB`}</Meter.Value>
<Meter.Track>
<Meter.Indicator />
</Meter.Track>
</Meter.Root>
<p className="text-small text-danger">Nearly full — upgrade to add space.</p>
</Surface>
);
}
Anatomy
Compose a meter from its parts. Meter.Root owns the value (value, with optional min/max, default 0–100) and the shared tone, size, and labels.
Meter.Root— the container. Props:value,min/max,low/high/optimum(the threshold model),tone(explicit, overrides the derived one),size(defaultmd),labels(defaulttop),thickness,format(anIntl.NumberFormatOptions), plus the Base UIMeter.Rootprops (getAriaValueText,aria-valuetext,locale). Give it anaria-label.Meter.Track— the groove the indicator fills against. Holds a singleMeter.Indicator.Meter.Indicator— the filled portion, sized to the value; its fill colour follows the resolvedtoneand eases as the value changes.Meter.Label— the visible caption. Laid out by the root'slabelsaxis.Meter.Value— the formatted value text, localized via the active locale; pass a render child(formattedValue, value) => …for a qualitative reading.
Every part carries a data-slot (meter, meter-track, meter-indicator, meter-label, meter-value) for host-side styling — pair the root slot with the data-tone, data-size, and data-labels axes it stamps. The reading is exposed through the Base UI meter role (aria-valuenow / aria-valuetext) and the fill is RTL-aware.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The track and fill follow the radius knob (--noctis-radius-control) rather than a baked-in pill, so a RadiusScope or the System Controls retune them with every other control. The minted tokens are the public override seam: set one on any ancestor and every meter in that region retunes — e.g. .dashboard { --noctis-meter-track-block-size: 0.625rem; } thickens every bar (or pass thickness per instance). See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI (Track, Indicator, Label, Value) list just the props they pass through. Expand a row for the full type and description.