Meter
One value on a track — as a bar, or as a ruler of ticks with a target.
Quota attained68%
Against a 75% target68%
The idea
The bar's fill reveals the whole scale, so its tip is the value's colour. The ruler is the same reading as a row of ticks, each lit in the scale colour at its own position, so it is the scale read left to right, stopping where the value does. Both are meters, not progress bars — they report a measurement, and nothing about them is still loading.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Meter, Ruler } from "mihirmodi-ui";
<Meter value={68} label="Quota attained" />
<Ruler value={68} target={75} label="Quota attained against target" />Props
| Prop | Type | Notes |
|---|---|---|
| value / maxnumber | number= — / 100 | The reading and the top of the track. |
| labelstring | string | Required. A bar alone says nothing about what it measures. |
| good"high" | "low" | "high" | "low"= "high" | Flip the scale. |
| Ruler: targetnumber | number | A dashed mark for where it should be. |
| Ruler: ticksnumber | number= 48 | How many ticks; every sixth is a major one. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { SCALE, ends, scaleColor, type Good } from "../../lib/scale.js";
import { useDrawIn } from "../../lib/useDrawIn.js";
export type MeterProps = {
value: number;
max?: number;
good?: Good;
/** The accessible name. Required — a bar alone says nothing about what it measures. */
label: string;
className?: string;
};
/**
* A single value on a track, whose fill reveals the whole scale — the colour at
* its tip is the value's, and a full meter reaches the scale's far end. It is a
* `meter`, not a progress bar: it reports a measurement, and nothing about it is
* still loading. It fills from empty the first time it is seen.
*/
export function Meter({ value, max = 100, good = "high", label, className = "" }: MeterProps) {
const { ref, drawn, animate } = useDrawIn<HTMLDivElement>();
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const [lo, hi] = ends(good);
return (
<div
ref={ref}
role="meter"
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label}
className={`h-1.5 overflow-hidden rounded-full bg-gray-200 ${className}`}
>
<span
className="block h-full rounded-full"
style={{
width: `${drawn ? pct : 0}%`,
backgroundImage: `linear-gradient(to right, ${lo}, ${SCALE.mid}, ${hi})`,
backgroundRepeat: "no-repeat",
backgroundSize: `${pct > 0 ? (100 / pct) * 100 : 100}% 100%`,
transition: animate ? "width 800ms cubic-bezier(0.16,1,0.3,1)" : undefined,
}}
/>
</div>
);
}
export type RulerProps = MeterProps & {
/** A dashed mark for where it should be. */
target?: number;
/** How many ticks. Every sixth is a major one. */
ticks?: number;
};
/**
* The same reading drawn as a row of ticks with a marker at the value. The ticks
* up to the value take the scale colour at their own position, so the ruler is
* the scale, read left to right, and stops where the value does. A dashed mark
* shows the target when there is one.
*/
export function Ruler({ value, max = 100, target, good = "high", label, ticks = 48, className = "" }: RulerProps) {
const { ref, drawn, animate } = useDrawIn<HTMLDivElement>();
const pct = Math.max(0, Math.min(100, (value / max) * 100));
return (
<div
ref={ref}
role="meter"
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label}
className={`relative h-[22px] ${className}`}
>
<div aria-hidden className="absolute inset-0 flex items-end justify-between">
{Array.from({ length: ticks + 1 }, (_, i) => {
const at = (i / ticks) * 100;
const lit = drawn && at <= pct;
return (
<span
key={i}
className={`w-[1.5px] rounded-[1px] ${i % 6 === 0 ? "h-4" : "h-2.5"}`}
style={{
background: lit ? scaleColor(good === "low" ? 1 - at / 100 : at / 100) : "var(--color-gray-400)",
transition: animate ? `background-color 200ms ease-out ${i * 12}ms` : undefined,
}}
/>
);
})}
</div>
{target !== undefined && (
<span aria-hidden className="absolute inset-y-0 w-px -translate-x-1/2 border-l border-dashed border-gray-600" style={{ left: `${(target / max) * 100}%` }} />
)}
<span
aria-hidden
className="absolute inset-y-0 w-0.5 -translate-x-1/2 rounded-[1px] bg-ink"
style={{ left: `${drawn ? pct : 0}%`, transition: animate ? "left 800ms cubic-bezier(0.16,1,0.3,1)" : undefined }}
/>
</div>
);
}
/**
* The value scale every chart reads from.
*
* A quantity is a position between low and high, and its mark takes the scale
* colour at that position — so the same value is the same colour in a bar, a
* dot, a ring and a meter anywhere on the page. `good="low"` flips it for
* metrics where less is better. A comparison series stays in the flat context
* colour, so the subject and its reference never compete.
*
* The colours are mixed in CSS rather than computed here, so a theme flip (or
* your own redeclared stops) re-colours every mark without a re-render.
*/
export type Good = "high" | "low";
export const SCALE = { low: "var(--scale-low)", mid: "var(--scale-mid)", high: "var(--scale-high)" } as const;
export const CONTEXT = "var(--chart-context)";
export const GRID = "var(--chart-grid)";
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
/** The colour at `t` ∈ [0, 1] on the low → mid → high scale, as a CSS value. */
export function scaleColor(t: number) {
const u = clamp01(t);
return u < 0.5
? `color-mix(in oklab, ${SCALE.low}, ${SCALE.mid} ${(u * 200).toFixed(1)}%)`
: `color-mix(in oklab, ${SCALE.mid}, ${SCALE.high} ${((u - 0.5) * 200).toFixed(1)}%)`;
}
/** Where `v` sits between `min` and `max`, flipped when low is good. */
export function scaleT(v: number, min: number, max: number, good: Good = "high") {
const t = max === min ? 0.5 : (v - min) / (max - min);
return good === "low" ? 1 - t : t;
}
/** The scale's two ends, in the order a gradient should run for `good`. */
export const ends = (good: Good = "high") => (good === "low" ? [SCALE.high, SCALE.low] : [SCALE.low, SCALE.high]);
/** A round axis maximum above `v`: 1, 2, 2.5 or 5 times a power of ten. */
export function niceMax(v: number) {
if (v <= 0) return 1;
const p = Math.pow(10, Math.floor(Math.log10(v)));
const n = v / p;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p;
}
export type Format = (v: number) => string;
/** A number with at most `digits` decimals, in the reader's locale — unless you pass your own `format`. */
export function formatter(digits = 0, format?: Format): Format {
if (format) return format;
const nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: digits });
return (v) => nf.format(v);
}