Skip to content
  1. Metrics
  2. Meter

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

PropDefaultDescription
Meterrenders a <div> element
value-Current value.
max100Value that fills the track.
colorbg-zinc-900 dark:bg-whiteBackground classes of the fill, `bg-amber-500` to flag a threshold.
sizemd`md` is 6px tall, `sm` 4px for the micro meter under a stat.
subtlefalseLighter track, for a meter that is one row among many.
animatedfalseAnimate 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.
asdiv`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>