Meter
Reports a measurement inside a known range, like disk usage or a score.
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.
384 GB of 512 GB, 75% full
<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.Labelor give the root anaria-label. The label part wiresaria-labelledbyfor 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-valuenowreports the clamped value, so clamp before you pass it in where the source can overshoot. options.formatresolves against the runtime locale unless you pin one withoptions.locale. If the server and the client can differ, pass both, or fix the digits withminimumFractionDigitsandmaximumFractionDigits.
Anatomy
Assemble the parts, in whatever order the layout needs.
| Name | Required | Description |
|---|---|---|
| Meter.Root | true | Renders a <div role="meter">. Owns the value and writes the percentage. |
| Meter.Label | Renders a <span> that names the meter through aria-labelledby. | |
| Meter.Track | Renders a <div>. Structural: the full length of the bar. | |
| Meter.Indicator | Renders a <div>, sized to the current percentage. | |
| Meter.Value | Renders 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
Options
Emits
None. Meter is a readout.
Slot props
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
Meter.Value
Renders a <span> with the formatted value. It is not hidden from screen
readers.
Slot props
Composable
useMirrorMeter(id) reaches a meter from anywhere in the app.
Data attributes
Every part writes the same values, so any of them can be styled from the state without a wrapper class.
CSS variables
Errors
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.