FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • ToggleGroup
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Toolbar
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • NumberField
  • Field & Form
  • Progress
  • Meter

Resources

  • Roadmap & changelog
  • GitHub ↗

Meter

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.

Personal storage1.2 GB of 5 GB
Team storage4.1 GB of 5 GB
Archive storage4.6 GB of 5 GB
Show code
MeterStorage.tsx
// 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.css
/* 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.

Anatomy

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).

Regions

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 isvaluedata-region
optimum < low (lower is better)value <= lowoptimum
low < value <= highsuboptimum
high < valueeven-less-good
high < optimum (higher is better)high <= valueoptimum
low <= value < highsuboptimum
value < loweven-less-good
low <= optimum <= high (the middle is best)low <= value <= highoptimum
otherwisesuboptimum (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").

Next to a Field's control

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.

StrengthToo short
Show code
MeterStrength.tsx
"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.css
/* 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;
  }
}

Props

Meter.Root

PropTypeDefaultDescription
valuenumber–Required. Clamped to [min, max].
minnumber0The start of the range.
maxnumber100The end of the range (the platform's default is 1). max <= min warns in development and renders 0%.
lownumberminUpper bound of the low part of the range. A non-finite number counts as absent (the default applies).
highnumbermaxLower bound of the high part of the range. A non-finite number counts as absent (the default applies).
optimumnumber(min + max) / 2The best value; decides which part of the range is optimum. A non-finite number counts as absent (the default applies).
formatIntl.NumberFormatOptions{ style: "percent" }As on Progress: the percent style formats the fraction, any other style the value.
localestringthe runtime'sBCP 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.
classNamestring | ((state: MeterState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MeterState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MeterState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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.Value

As on Progress, with MeterState. Meter.Value shows formattedValue by default and is aria-hidden unless you pass aria-hidden={false}.

PropTypeDefaultDescription
classNamestring | ((state: MeterState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MeterState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MeterState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: MeterState) => ReactNode)–Replace the default <div | span> (element form) or take over rendering (function form).

State

FieldTypeMeaning
valuenumberClamped to [min, max].
min, maxnumberThe range.
low, high, optimumnumberAfter the defaults and clamping.
percentagenumber0 to 100.
formattedValuestringThe Intl.NumberFormat text.
valueTextstring | nullWhat aria-valuetext carries.
region"optimum" | "suboptimum" | "even-less-good"See Regions.

Data attributes and custom properties

NameOnMeaning
data-regionevery partoptimum, suboptimum or even-less-good.
--meter-valueRoot (inherited)The percentage.
--meter-low, --meter-high, --meter-optimumRoot (inherited)The clamped boundaries as percentages, for drawing the zones.

Accessibility

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.