Meter
A thin track that shows usage against a quota, the progress of a flow or a share of a total, with the number always written next to it.
API calls
8,400 of 10,000 this month
import { Meter } from '@/components/meter'
export default function Example() {
return (
<div className="w-full max-w-sm">
<div className="flex items-baseline justify-between gap-4">
<p className="truncate text-sm/6 font-medium text-zinc-950">API calls</p>
<p className="shrink-0 text-sm/6 text-zinc-500">8,400 of 10,000 this month</p>
</div>
<Meter value={8400} max={10000} className="mt-2" />
</div>
)
}<script setup lang="ts">
import Meter from '@/components/meter.vue'
</script>
<template>
<div class="w-full max-w-sm">
<div class="flex items-baseline justify-between gap-4">
<p class="truncate text-sm/6 font-medium text-zinc-950">API calls</p>
<p class="shrink-0 text-sm/6 text-zinc-500">8,400 of 10,000 this month</p>
</div>
<Meter :value="8400" :max="10000" class="mt-2" />
</div>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
export function Meter({
value,
max = 100,
color = 'bg-zinc-900 dark:bg-white',
size = 'md',
subtle = false,
animated = false,
label,
valueText,
as: Component = 'div',
className,
style,
...props
}: {
value: number
max?: number
color?: string
size?: 'sm' | 'md'
subtle?: boolean
animated?: boolean
label?: string
valueText?: string
as?: 'div' | 'span'
} & React.ComponentPropsWithoutRef<'div'>) {
let percent = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0
let current = Math.min(Math.max(value, 0), Math.max(max, 0))
return (
<Component
role={label ? 'meter' : undefined}
aria-label={label}
aria-valuenow={label ? current : undefined}
aria-valuemin={label ? 0 : undefined}
aria-valuemax={label ? max : undefined}
aria-valuetext={label ? valueText : undefined}
aria-hidden={label ? undefined : 'true'}
{...props}
style={{ ...style, '--meter-value': `${percent}%` } as React.CSSProperties}
className={clsx(
className,
'block overflow-hidden rounded-full dark:bg-white/10 forced-colors:outline',
size === 'sm' ? 'h-1' : 'h-1.5',
subtle ? 'bg-zinc-950/5' : 'bg-zinc-950/10'
)}
>
<Component
className={clsx(
'block h-full w-(--meter-value) rounded-full forced-colors:bg-[CanvasText] forced-colors:forced-color-adjust-none',
color,
animated && 'transition-[width] duration-300 motion-reduce:transition-none'
)}
/>
</Component>
)
}<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
value: number
max?: number
color?: string
size?: 'sm' | 'md'
subtle?: boolean
animated?: boolean
label?: string
valueText?: string
as?: 'div' | 'span'
}>(),
{ max: 100, color: 'bg-zinc-900 dark:bg-white', size: 'md', subtle: false, animated: false, as: 'div' }
)
const percent = computed(() => (props.max > 0 ? Math.min(100, Math.max(0, (props.value / props.max) * 100)) : 0))
const current = computed(() => Math.min(Math.max(props.value, 0), Math.max(props.max, 0)))
</script>
<template>
<component
:is="as"
:role="label ? 'meter' : undefined"
:aria-label="label"
:aria-valuenow="label ? current : undefined"
:aria-valuemin="label ? 0 : undefined"
:aria-valuemax="label ? max : undefined"
:aria-valuetext="label ? valueText : undefined"
:aria-hidden="label ? undefined : 'true'"
class="block overflow-hidden rounded-full dark:bg-white/10 forced-colors:outline"
:class="[size === 'sm' ? 'h-1' : 'h-1.5', subtle ? 'bg-zinc-950/5' : 'bg-zinc-950/10']"
:style="{ '--meter-value': `${percent}%` }"
>
<component
:is="as"
class="block h-full w-(--meter-value) rounded-full forced-colors:bg-[CanvasText] forced-colors:forced-color-adjust-none"
:class="[color, animated && 'transition-[width] duration-300 motion-reduce:transition-none']"
/>
</component>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Meterrenders a <div> element | ||
value | - | Current value. |
max | 100 | Value that fills the track. |
color | bg-zinc-900 dark:bg-white | Background classes of the fill, `bg-amber-500` to flag a threshold. |
size | md | `md` is 6px tall, `sm` 4px for the micro meter under a stat. |
subtle | false | Lighter track, for a meter that is one row among many. |
animated | false | Animate the fill when the value changes. |
label | - | Accessible name; the meter then exposes its value, kept between 0 and `max`. Without it the track is hidden from assistive tech, the text next to it does the job. |
valueText | - | Read out instead of the raw number when `label` is set, `1.2 GB of 2 GB` for example. |
as | div | `span` when the meter sits inside inline content, a `<p>` for example. |
Examples
Basic example
Put the label and the value on a baseline row above the track, then the meter with mt-2. The meter itself is decorative, the row says the number:
API calls
8,400 of 10,000 this month
import { Meter } from '@/components/meter'
export default function Example() {
return (
<div className="w-full max-w-sm">
<div className="flex items-baseline justify-between gap-4">
<p className="truncate text-sm/6 font-medium text-zinc-950">API calls</p>
<p className="shrink-0 text-sm/6 text-zinc-500">8,400 of 10,000 this month</p>
</div>
<Meter value={8400} max={10000} className="mt-2" />
</div>
)
}<script setup lang="ts">
import Meter from '@/components/meter.vue'
</script>
<template>
<div class="w-full max-w-sm">
<div class="flex items-baseline justify-between gap-4">
<p class="truncate text-sm/6 font-medium text-zinc-950">API calls</p>
<p class="shrink-0 text-sm/6 text-zinc-500">8,400 of 10,000 this month</p>
</div>
<Meter :value="8400" :max="10000" class="mt-2" />
</div>
</template>Thresholds
Switch color when a limit is reached. The track never changes, only the fill:
- API calls
- 8400 of 10000 this month
- Team members
- 5 of 5
- Webhook endpoints
- 3 of 10
<Meter value={used} max={limit} color={used >= limit ? 'bg-amber-500' : undefined} className="mt-2" /><template>
<Meter :value="used" :max="limit" :color="used >= limit ? 'bg-amber-500' : undefined" class="mt-2" />
</template>Rows of a breakdown
Use subtle when the meter is one row among many, so the tracks stay in the background of the list:
- Insufficient funds46%
- Provider timeout31%
- Declined by payer23%
<ul role="list" className="space-y-3">
{rows.map((row) => (
<li key={row.label}>
<div className="flex items-baseline gap-4 text-sm/6">
<span className="min-w-0 flex-1 truncate text-zinc-950">{row.label}</span>
<span className="w-12 shrink-0 text-right font-medium text-zinc-950 tabular-nums">{row.share}%</span>
</div>
<Meter value={row.share} subtle color="bg-amber-600" className="mt-1.5" />
</li>
))}
</ul><template>
<ul role="list" class="space-y-3">
<li v-for="row in rows" :key="row.label">
<div class="flex items-baseline gap-4 text-sm/6">
<span class="min-w-0 flex-1 truncate text-zinc-950">{{ row.label }}</span>
<span class="w-12 shrink-0 text-right font-medium text-zinc-950 tabular-nums">{{ row.share }}%</span>
</div>
<Meter :value="row.share" subtle color="bg-amber-600" class="mt-1.5" />
</li>
</ul>
</template>Micro meter under a stat
size="sm" draws a 4px track, for a rate under a large number:
- Success rate
- 96%
<dt className="text-sm/6 text-zinc-500">Success rate</dt>
<dd className="text-2xl/8 font-semibold text-zinc-950 tabular-nums">96%</dd>
<Meter value={96} size="sm" subtle color="bg-green-500" className="mt-1" /><template>
<dt class="text-sm/6 text-zinc-500">Success rate</dt>
<dd class="text-2xl/8 font-semibold text-zinc-950 tabular-nums">96%</dd>
<Meter :value="96" size="sm" subtle color="bg-green-500" class="mt-1" />
</template>Announced progress
Give the meter a label when nothing next to it states the value: it exposes role="meter" with the current, min and max values, and valueText reads them as words. animated eases the fill from one value to the next:
<Meter value={3} max={4} label="Onboarding progress" valueText="Step 3 of 4" animated /><template>
<Meter :value="3" :max="4" label="Onboarding progress" value-text="Step 3 of 4" animated />
</template>