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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | What is measured. | |
| value | number | The reading. | |
| min | number | 0 | Bottom of the range. |
| max | number | 100 | Top of the range. |
| low | number | 25% of the range | Values below this are low. |
| high | number | 75% of the range | Values above this are high. |
| optimum | number | max | The best value. Decides which zone is good. |
| format | (value: number) => string | Formats 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.