← All components

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Meter — usage4 lines
import { Meter, Ruler } from "mihirmodi-ui";

<Meter value={68} label="Quota attained" />
<Ruler value={68} target={75} label="Quota attained against target" />

Props

PropNotes
value / maxnumberThe reading and the top of the track.
labelstringRequired. A bar alone says nothing about what it measures.
good"high" | "low"Flip the scale.
Ruler: targetnumberA dashed mark for where it should be.
Ruler: ticksnumberHow 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.

packages/ui/src/components/data/Meter.tsx101 lines
"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>
  );
}


packages/ui/src/lib/scale.ts54 lines
/**
 * 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);
}