Skip to content

Meter

Reports a measurement inside a known range, like disk usage or a score.

View source View as Markdown

Meter reports where a measurement sits inside a range that is known up front: disk usage, a password score, how much of a budget is left. Useful whenever the number is a level rather than the state of a task; for something that starts, runs and finishes, use Progress.

Storage75%

384 GB of 512 GB, 75% full

App.vue
<template>
  <div class="flex w-64 flex-col gap-5">
    <Meter.Root
      v-slot="{ percentage }"
      :value="used"
      :options="{ max: total }"
      class="flex flex-col gap-2"
    >
      <div
        class="type-surface-code text-surface-muted flex items-baseline justify-between"
      >
        <Meter.Label>Storage</Meter.Label>
        <Meter.Value class="tabular-nums" />
      </div>

      <Meter.Track :class="track">
        <Meter.Indicator :class="indicator" />
      </Meter.Track>

      <p class="type-surface-caption text-surface-muted tabular-nums">
        {{ used }} GB of {{ total }} GB, {{ Math.round(percentage) }}% full
      </p>
    </Meter.Root>

    <div class="flex gap-2">
      <Button :class="button" @click="step(-64)">Free 64 GB</Button>
      <Button :class="button" @click="step(64)">Add 64 GB</Button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Button, Meter } from '@maas/mirror/vue'

const total = 512
const used = ref(384)

function step(amount: number) {
  used.value = Math.min(total, Math.max(0, used.value + amount))
}

const track = 'rounded-component-round bg-primary-subtle h-2.5 overflow-hidden'

const indicator = [
  'bg-primary-solid h-full',
  'transition-all duration-300 ease-linear',
].join(' ')

const button = [
  'rounded-component-md border-surface border px-2.5 py-1',
  'type-component-xs text-surface-muted hover:text-surface',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
].join(' ')
</script>

Usage guidelines

  • A meter has no implicit name, so render a Meter.Label or give the root an aria-label. The label part wires aria-labelledby for you.
  • The value is always a number, so leave the meter unrendered until there is something to report.
  • Only the percentage and the formatted string are clamped, and aria-valuenow reports the clamped value, so clamp before you pass it in where the source can overshoot.
  • options.format resolves against the runtime locale unless you pin one with options.locale. If the server and the client can differ, pass both, or fix the digits with minimumFractionDigits and maximumFractionDigits.

Anatomy

Assemble the parts, in whatever order the layout needs.

NameRequiredDescription
Meter.Root true Renders a <div role="meter">. Owns the value and writes the percentage.
Meter.LabelRenders a <span> that names the meter through aria-labelledby.
Meter.TrackRenders a <div>. Structural: the full length of the bar.
Meter.IndicatorRenders a <div>, sized to the current percentage.
Meter.ValueRenders a <span> with the formatted value.
<script setup lang="ts">
import { Meter } from '@maas/mirror/vue'
</script>

<template>
  <Meter.Root :value="used" :options="{ max: total }">
    <Meter.Label>Storage</Meter.Label>

    <Meter.Track>
      <Meter.Indicator />
    </Meter.Track>

    <Meter.Value />
  </Meter.Root>
</template>

Examples

Naming the meter

Meter.Label renders a <span>, takes an ID from the root and points the root’s aria-labelledby at itself.

<template>
  <Meter.Root :value="score" :options="{ max: 4 }">
    <Meter.Label>Password strength</Meter.Label>

    <Meter.Track>
      <Meter.Indicator />
    </Meter.Track>
  </Meter.Root>
</template>

Leave the label out where the surrounding copy already names the meter, and give the root an aria-label instead.

<template>
  <Meter.Root
    :value="score"
    :options="{ max: 4 }"
    aria-label="Password strength"
  >
    <Meter.Track>
      <Meter.Indicator />
    </Meter.Track>
  </Meter.Root>
</template>

Sizing the indicator

Meter.Root writes the percentage to --mme-percentage, a unitless number between 0 and 100, and the CSS we ship turns it into the indicator’s width.

.mirror-meter-indicator[data-orientation='horizontal'] {
  block-size: 100%;
  inline-size: calc(var(--mme-percentage, 0) * 1%);
}

The property sits on the root, so anything inside the component can read it, including a tick mark, a moving label or a conic-gradient ring.

.ring {
  background: conic-gradient(
    var(--app-color-primary-bg-solid) calc(var(--mme-percentage, 0) * 1%),
    var(--app-color-surface-bg-higher) 0
  );
}

Vertical meters

Set options.orientation to vertical and every part says so through data-orientation, while the indicator fills along the block axis instead.

<template>
  <Meter.Root :value="load" :options="{ orientation: 'vertical' }">
    <Meter.Track class="track">
      <Meter.Indicator />
    </Meter.Track>
  </Meter.Root>
</template>

<style>
.track {
  block-size: 8rem;
  display: flex;
  align-items: flex-end;
}
</style>

The track keeps its thickness on the free axis, so --mirror-meter-track-size becomes an inline size rather than a block one.

Formatting the value

options.format drives both Meter.Value and aria-valuetext, and defaults to { style: 'percent' }, which formats the percentage rather than the value.

<template>
  <Meter.Root :value="384" :options="{ max: 512 }" aria-label="Storage">
    <Meter.Value />
    <!-- 75% -->
  </Meter.Root>
</template>

Any other style formats the clamped value instead, which is how you report raw units rather than a share of the whole.

<template>
  <Meter.Root
    :value="384"
    :options="{ max: 512, format: { style: 'unit', unit: 'gigabyte' } }"
    aria-label="Storage"
  >
    <Meter.Value />
    <!-- 384 GB -->
  </Meter.Root>
</template>

Pass options.locale where the runtime locale is not the one you want, and both the rendered value and aria-valuetext follow it.

<template>
  <Meter.Root
    :value="384"
    :options="{
      max: 512,
      locale: 'de-DE',
      format: { style: 'unit', unit: 'gigabyte' },
    }"
    aria-label="Speicher"
  >
    <Meter.Value />
    <!-- 384 GB, with German separators -->
  </Meter.Root>
</template>

Take the slot to put the formatted string in markup of your own.

<template>
  <Meter.Value v-slot="{ formatted, percentage }">
    {{ formatted }} · {{ Math.round(percentage) }} of 100
  </Meter.Value>
</template>

Overriding the announcement

options.getAriaValueText receives the formatted string and the raw value, and what it returns replaces aria-valuetext entirely.

<template>
  <Meter.Root
    :value="used"
    :options="{
      max: total,
      getAriaValueText: (formatted, value) => `${value} of ${total} GB used`,
    }"
  >
    <Meter.Label>Storage</Meter.Label>

    <Meter.Track>
      <Meter.Indicator />
    </Meter.Track>
  </Meter.Root>
</template>

Driving it from elsewhere

Give the root an id and useMirrorMeter(id) reaches it from anywhere in the app, which is how a meter in a sidebar follows a number computed in a store.

<script setup lang="ts">
import { useMirrorMeter } from '@maas/mirror/vue'

const { percentage, setValue } = useMirrorMeter('storage')
</script>

<template>
  <Button @click="setValue(512)">Fill up</Button>

  <Meter.Root id="storage" :options="{ max: 512 }">
    <Meter.Label>Storage</Meter.Label>

    <Meter.Track>
      <Meter.Indicator />
    </Meter.Track>
  </Meter.Root>
</template>

API reference

Module. A bundled options object, and useMirrorMeter(id) as the programmatic API.

Meter.Root

Renders a <div role="meter">.

Props

PropTypeDefault
id
stringgenerated
value
number0
options
MeterOptionssee below

Options

OptionTypeDefault
min
number0
max
number100
orientation
'horizontal' | 'vertical''horizontal'
locale
Intl.LocalesArgumentundefined
format
Intl.NumberFormatOptions{ style: 'percent' }
getAriaValueText
(formatted: string, value: number) => stringundefined

Emits

None. Meter is a readout.

Slot props

PropType
value
number
percentage
number
orientation
'horizontal' | 'vertical'
formatted
string

Meter.Label

Renders a <span> carrying the ID the root points aria-labelledby at. No props of its own beyond id.

Meter.Track

Renders a <div>, the full length of the bar and the box the indicator is measured against. No props of its own beyond id.

Meter.Indicator

Renders a <div>, sized to calc(var(--mme-percentage, 0) * 1%) on the axis the orientation fills and 100% on the other. No props of its own beyond id.

Slot props

PropType
percentage
number
orientation
'horizontal' | 'vertical'

Meter.Value

Renders a <span> with the formatted value. It is not hidden from screen readers.

Slot props

PropType
value
number
percentage
number
formatted
string

Composable

useMirrorMeter(id) reaches a meter from anywhere in the app.

KeyType
value
ComputedRef<number>
percentage
ComputedRef<number>
formatted
ComputedRef<string>
setValue
(next: number) => void

Data attributes

Every part writes the same values, so any of them can be styled from the state without a wrapper class.

PartAttributeValue
all
data-orientation
horizontal | vertical
all
data-scope
root | track | indicator | label | value

CSS variables

PartVariableDefault
Meter.Track
--mirror-meter-track-size
0.5rem

Errors

Code
missing_meter_context
invalid_meter_range

Accessibility

Meter.Root renders a role="meter" with aria-valuemin, aria-valuemax, aria-valuenow and aria-valuetext, and takes no focus and no keyboard interaction.

Assistive technology treats a meter as a gauge, so anything the user can change belongs in a Slider, and anything with a beginning and an end belongs in Progress.

Meter.Value stays readable by assistive technology, because a reader that lands on the meter itself hears aria-valuetext and a reader moving through the text hears the value part.

aria-labelledby is written once Meter.Label has registered, which happens on the tick after the root renders. Where a name has to be present in the first paint, such as server-rendered markup, pass an aria-label as well.