Photon Design System

Meter

Displays a scalar measurement within a known range — disk usage, quota, a score. Built on Base UI Meter. Unlike Progress, a meter is not about a task advancing over time; it’s a static reading at a point in time.

Import

import { Meter } from "@photon-ai/pho-ui/components/meter";

Basic

Set value on Meter.Root (0–100 by default). Compose a header row with Meter.Label and Meter.Value; with no format, the value reads as a percentage of the range.

Storage used
x
<Meter.Root value={72}>
  <div className="flex items-center justify-between">
    <Meter.Label>Storage used</Meter.Label>
    <Meter.Value />
  </div>
  <Meter.Track>
    <Meter.Indicator />
  </Meter.Track>
</Meter.Root>

Range and formatting

Pass min / max to set the real bounds, and format (an Intl.NumberFormatOptions) to render the value in its own units instead of a percentage — bytes, GB, currency, a score out of five.

Disk
x
<Meter.Root
  value={168}
  max={256}
  format={{ style: "unit", unit: "gigabyte", unitDisplay: "short" }}
>
  <div className="flex items-center justify-between">
    <Meter.Label>Disk</Meter.Label>
    <Meter.Value />
  </div>
  <Meter.Track>
    <Meter.Indicator />
  </Meter.Track>
</Meter.Root>

Levels

The value maps to a level, so the fill can carry meaning. Override Meter.Indicator’s fill with a semantic token past a threshold, and use Meter.Value’s render prop for a raw “used / total” reading instead of a percentage.

Team seats
x
Build minutes
x
<Meter.Root value={1880} max={2000}>
  <div className="flex items-center justify-between">
    <Meter.Label>Build minutes</Meter.Label>
    <Meter.Value>{(_, v) => `${v} / 2000`}</Meter.Value>
  </div>
  <Meter.Track>
    <Meter.Indicator className="bg-pho-error-solid" />
  </Meter.Track>
</Meter.Root>

Anatomy

  • Meter.Root — owns value, min, max; renders role="meter"
  • Meter.Label — names the measurement
  • Meter.Value — renders the formatted value (percentage by default)
  • Meter.Track — the rail
  • Meter.Indicator — the fill; width is managed by Base UI

Props

Every part also accepts className and render (Base UI’s prop for swapping the underlying element).

Meter.Root (<div role="meter">)

  • value — number (required) — the current reading
  • min — number (default 0) — start of the range
  • max — number (default 100) — end of the range
  • format — Intl.NumberFormatOptions — how Meter.Value formats the number; without it the value reads as a percentage of the range
  • locale — Intl.LocalesArgument — locale for formatting; defaults to the runtime locale
  • getAriaValueText — (formattedValue, value) => string — human-readable text for aria-valuetext
  • aria-valuetext — string — static value text for assistive tech

Meter.Value (<span aria-hidden>)

  • children — (formattedValue, value) => ReactNode — render-prop override; defaults to the formatted percentage

Meter.Label (<span>), Meter.Track (<div>), Meter.Indicator (<div>) take only the common className / render; the indicator’s inline size is set by Base UI from value.

Accessibility

  • Semantics — Meter.Root renders role="meter" with aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext, exposing the reading to assistive tech. Meter.Value is aria-hidden (its text duplicates aria-valuetext), so the number isn’t announced twice.
  • Labelling — a Meter.Label is linked via aria-labelledby. With no visible label, pass aria-label on Meter.Root so the meter isn’t anonymous; getAriaValueText supplies a friendlier reading (“168 of 256 GB”).
  • Not interactive — it’s a static reading, not a control: nothing to focus and no keyboard interaction.
  • Announcements — the meter role reports the current value but isn’t a live region, so changes aren’t pushed to assistive tech. A meter is for a stable measurement, not a running commentary — use Progress for that.

Best practices

  • Choose Meter over Progress when the number reflects a level, not a task in flight. “Storage used” is a meter; “Uploading…” is progress.
  • Pair the bar with a label and value so the reading is legible without estimating the fill by eye.
  • Keep the range honest — set min/max to the real bounds so the fill maps to the actual proportion.
  • Reach for a semantic fill only to signal a real threshold (near quota, low balance); color for its own sake dilutes the signal.