Skip to content
goodtabs UI
BrowseBrowse components

Meter

A reading against low, high and optimum, with the real meter role.

npx shadcn@latest add https://ui.goodtabs.co/r/meter.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/meter

Contrast score

82 / 100

Higher is better. Ticks mark the low and high edges.

Lower is better

Largest contentful paint

1.8 s

Layout shift

0.31

Interaction delay

420 ms

Set optimum to 0 and the good zone flips to the low end.

Optimum in the middle

Bundle budget

118 kB

Storage

188 kB

Too little and too much both read as worse than the middle.

Usage

import { Meter } from "@/components/ui/meter"

<Meter label="Contrast score" value={82} />

Props

PropTypeDefaultDescription
labelstringWhat is measured.
valuenumberThe reading.
minnumber0Bottom of the range.
maxnumber100Top of the range.
lownumber25% of the rangeValues below this are low.
highnumber75% of the rangeValues above this are high.
optimumnumbermaxThe best value. Decides which zone is good.
format(value: number) => stringFormats the reading on the right.

Source

meter/meter.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

type Zone = "low" | "optimum" | "high";

type MeterProps = Omit<React.ComponentProps<"div">, "children" | "role"> & {
  /** What is measured. Used as the accessible name and the visible label. */
  label: string;
  /** The reading. */
  value: number;
  min?: number;
  max?: number;
  /** Values at or below this are low. */
  low?: number;
  /** Values at or above this are high. */
  high?: number;
  /** The best value. Decides whether low, middle or high is the good end. */
  optimum?: number;
  /** Formats the reading shown on the right. */
  format?: (value: number) => string;
};

/** Same rule as the HTML meter element: which of the three zones the optimum sits in. */
function zoneOf(value: number, low: number, high: number): Zone {
  return value < low ? "low" : value > high ? "high" : "optimum";
}

const STATUS = { good: "Good", fair: "Fair", poor: "Poor" } as const;

/**
 * A scalar reading against a range, with the real meter role. Unlike progress it has no
 * end state: the question is whether the value sits in the good zone. Hairline ticks mark
 * `low` and `high`; the status is written out, never carried by colour.
 */
export function Meter({
  label,
  value,
  min = 0,
  max = 100,
  low = min + (max - min) * 0.25,
  high = min + (max - min) * 0.75,
  optimum = max,
  format = (v) => String(v),
  className,
  ...props
}: MeterProps) {
  const span = max - min;
  const clamped = Math.min(Math.max(value, min), max);
  const pct = (v: number) => (span > 0 ? ((v - min) / span) * 100 : 0);
  const optimumZone = zoneOf(optimum, low, high);
  const valueZone = zoneOf(clamped, low, high);
  const status =
    valueZone === optimumZone ? "good" : valueZone === "optimum" || optimumZone === "optimum" ? "fair" : "poor";
  const reading = format(value);

  return (
    <div className={cn("flex w-full flex-col gap-1.5", className)} {...props}>
      <div className="flex items-baseline justify-between gap-3">
        <p className="m-0 truncate text-sm text-foreground">{label}</p>
        <p className="m-0 shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
          {reading}
          <span aria-hidden="true"> · {STATUS[status]}</span>
        </p>
      </div>
      <div
        role="meter"
        aria-label={label}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={clamped}
        aria-valuetext={`${reading}, ${STATUS[status].toLowerCase()}`}
        data-status={status}
        className="relative h-1.5 w-full rounded-full bg-muted"
      >
        <div
          data-slot="meter-fill"
          className={cn(
            "h-full rounded-full transition-[width] duration-200 ease-out motion-reduce:transition-none",
            status === "good" ? "bg-foreground" : status === "fair" ? "bg-foreground/60" : "bg-foreground/35",
          )}
          style={{ width: `${pct(clamped)}%` }}
        />
        {[low, high].map((tick) => (
          <span
            key={tick}
            aria-hidden="true"
            data-slot="meter-tick"
            className="absolute -top-0.5 -bottom-0.5 w-px bg-border"
            style={{ left: `${pct(tick)}%` }}
          />
        ))}
      </div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/meter/prompt.md, or the Copy AI prompt button above.

When to use

  • A score or measurement with a good range, such as a performance metric
  • Capacity or quota where the middle is healthy

When not to use

  • Work that moves toward done: use progress-bar
  • Several values over time: use a chart

Agent notes

  • Status follows the HTML meter rules: the zone of the optimum decides which end is good
  • Good, fair and poor change the fill opacity and are also written as text; there is no red or green
  • The format prop is a function, so a client parent must be a client component
  • Server component; all state comes from props

The maths

  • Each tick sits at (v - min) / (max - min). With low 25 and high 75 on 0 to 100 they land at 25 and 75 percent.
  • Zone of a value v is low if v < low, high if v > high, else middle. Status is good when v and the optimum share a zone, poor when both differ from the middle, fair otherwise.