Meter Quota Rule

Meter

A quota meter with no bar, no fill, no pill: the printed reading ('38.2 GB of 100 GB') sits above a 4px hairline whose used portion is solid and remainder is dashed, a fixed tick marks the warning threshold, and crossing it thickens the rule and bolds the digits instead of turning anything red.

Install
npx shadcn add https://design.helpmarq.com/r/meter-quota-rule.json
Source
registry/core/meter-quota-rule/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// RationRule — a quota meter with no bar, no fill, no pill: just the printed
// reading ('38.2 GB of 100 GB') sitting directly above a 4px-tall hairline.
// The rule itself IS the meter — its used portion is a solid --foreground
// stroke, its remainder a dashed --border stroke (2 3), and the boundary
// between them eases with the value. A fixed tick marks the warning
// threshold in --warning, never on the fill; crossing it doesn't turn
// anything red, it thickens the solid stroke 1px -> 2px and steps the
// numerals from weight 400 to 600 — the line asserts itself instead of
// alarming. Meant to sit dozens-deep on a settings page (storage, seats,
// API credits, budget) at text scale. Pure inline SVG + CSS, no canvas.
// ---------------------------------------------------------------------------

export interface RationRuleProps {
  /** what's being rationed, e.g. "Storage" — shown as a caption and used as the meter's accessible name */
  label: string;
  /** amount currently used */
  value: number;
  /** total allowance */
  max: number;
  /** short unit printed after both numbers, e.g. "GB" */
  unit: string;
  /** full unit word spoken by aria-valuetext, e.g. "gigabytes" — defaults to `unit` */
  unitLabel?: string;
  /** fraction of max (0-1) at which the rule crosses into warning weight */
  warning?: number;
  className?: string;
}

const VIEW_W = 400;
const VIEW_H = 6;
const RULE_Y = VIEW_H / 2;
const MOVE_MS = 350;

function easeOutExpo(t: number) {
  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
}

function fmt(n: number) {
  if (!Number.isFinite(n)) return "0";
  const rounded = Math.round(n * 10) / 10;
  return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}

function clamp01(n: number) {
  if (Number.isNaN(n)) return 0;
  return Math.min(1, Math.max(0, n));
}

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

// eases `target` toward its resting value over `duration`ms with
// ease-out-expo, cleanly retargeting mid-flight from whatever the display
// value currently is. Snaps instantly when `reduced`.
function useEasedFraction(target: number, duration: number, reduced: boolean) {
  const [display, setDisplay] = useState(target);
  const displayRef = useRef(target);
  const fromRef = useRef(target);
  const toRef = useRef(target);
  const startRef = useRef(0);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    if (reduced) {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
      displayRef.current = target;
      fromRef.current = target;
      toRef.current = target;
      setDisplay(target);
      return;
    }
    if (target === toRef.current) return;
    fromRef.current = displayRef.current;
    toRef.current = target;
    startRef.current = performance.now();
    if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);

    const tick = (now: number) => {
      const t = Math.min(1, (now - startRef.current) / duration);
      const eased = easeOutExpo(t);
      const next = fromRef.current + (toRef.current - fromRef.current) * eased;
      displayRef.current = next;
      setDisplay(next);
      rafRef.current = t < 1 ? requestAnimationFrame(tick) : null;
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target, duration, reduced]);

  return display;
}

export function RationRule({
  label,
  value,
  max,
  unit,
  unitLabel,
  warning = 0.8,
  className = "",
}: RationRuleProps) {
  const uid = useId();
  const reduced = useReducedMotion();

  const safeMax = max > 0 ? max : 1e-6;
  const targetFraction = clamp01(value / safeMax);
  const warningFraction = clamp01(warning);

  const displayFraction = useEasedFraction(targetFraction, MOVE_MS, reduced);

  // final rest-state crossing drives ARIA (never mid-animation dependent);
  // the visual stroke width/weight track the live animated position, so the
  // thickening visibly happens AT the tick as the boundary sweeps past it.
  const crossedFinal = targetFraction >= warningFraction;
  const crossedLive = displayFraction >= warningFraction;

  const boundaryX = displayFraction * VIEW_W;
  const tickX = warningFraction * VIEW_W;

  const usedText = fmt(value);
  const maxText = fmt(max);
  const resolvedUnitLabel = unitLabel || unit;
  const stateText = crossedFinal
    ? "at or above warning threshold"
    : "below warning threshold";
  const valueText =
    `${usedText} of ${maxText} ${resolvedUnitLabel}, ${stateText}`.replace(
      /\s+/g,
      " "
    );

  const labelId = `${uid}-label`;

  return (
    <div className={className}>
      <style>{`
.ns-ration-solid{transition:stroke-width 200ms cubic-bezier(0.22,1,0.36,1)}
.ns-ration-weight{transition:font-weight 200ms cubic-bezier(0.22,1,0.36,1)}
@media (prefers-reduced-motion: reduce){
  .ns-ration-solid{transition:none}
  .ns-ration-weight{transition:none}
}
`}</style>

      <span id={labelId} className="block font-mono text-[11px] tracking-wide text-muted">
        {label.toUpperCase()}
      </span>

      <p
        className={
          "ns-ration-weight mt-1 text-sm tabular-nums text-foreground " +
          (crossedLive ? "font-semibold" : "font-normal")
        }
      >
        {usedText} {unit}{" "}
        <span className="text-muted">
          of {maxText} {unit}
        </span>
      </p>

      <div
        role="meter"
        aria-labelledby={labelId}
        aria-valuemin={0}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={valueText}
        className="mt-1.5"
      >
        <svg
          viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
          preserveAspectRatio="none"
          className="block h-1.5 w-full"
          aria-hidden
          focusable="false"
        >
          <line
            x1={boundaryX}
            y1={RULE_Y}
            x2={VIEW_W}
            y2={RULE_Y}
            vectorEffect="non-scaling-stroke"
            strokeWidth={1}
            strokeDasharray="2 3"
            strokeLinecap="butt"
            className="stroke-current text-border"
          />
          <line
            x1={0}
            y1={RULE_Y}
            x2={boundaryX}
            y2={RULE_Y}
            vectorEffect="non-scaling-stroke"
            strokeWidth={crossedLive ? 2 : 1}
            strokeLinecap="butt"
            className="ns-ration-solid stroke-current text-foreground"
          />
          <line
            x1={tickX}
            y1={0}
            x2={tickX}
            y2={VIEW_H}
            vectorEffect="non-scaling-stroke"
            strokeWidth={1}
            style={{ stroke: "var(--warning, #f5a623)" }}
          />
        </svg>
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {valueText}
      </p>
    </div>
  );
}
Use when

a single fraction-of-allowance reading (storage, seats, API credits, spend) meant to repeat dozens of times on one settings page at text scale, where the number itself is the primary reading and a bar/pill would be visual overkill; pick sparkline-automaton instead when the thing being shown is a SERIES trending against a rule over time rather than one static used/total fraction, or feeler-gap when the framing is a pass/fail value-vs-limit check rather than an ongoing allowance.

Build spec

Renders one quota — a live `value` against a `max`, both in `unit` — as nothing but its own printed reading sitting above a ruled hairline, with no bar, fill, or pill anywhere. Above the rule: a small mono `label` caption (e.g. 'STORAGE') and, directly under it, the composed readout '38.2 GB of 100 GB' in tabular-nums text, the used number and unit in full ink and the 'of X unit' remainder in --muted. Below that: a single inline SVG line 4px tall (viewBox 0 0 400 6, preserveAspectRatio=none, every stroke `vector-effect=non-scaling-stroke` so widths stay literal px regardless of the container's rendered width) drawn as three overlaid `<line>` elements sharing one horizontal axis: a dashed line from the used/remaining boundary to the far end, --border ink, `stroke-dasharray="2 3"`, representing the unused allowance; a solid line from 0 to that same boundary, --foreground ink, representing the used allowance, painted on top of the dashed line's origin so the seam never gapes; and a fixed vertical tick at the warning fraction (default 0.8 of max, overridable via `warning`), stroked in `var(--warning, #f5a623)` and nothing else in the whole component ever touches that token — the fill is always --foreground/--border, never colored by state, matching the rule that --warning marks a threshold, it doesn't recolor a value. On every `value`/`max` change the used/unused boundary — the shared x-coordinate the solid line ends at and the dashed line begins at — eases to its new position with a hand-rolled requestAnimationFrame tween (no dependency), ease-out-expo shaped (`1 - 2^(-10t)`), 350ms, cleanly retargetable mid-flight if a new value lands before the previous animation settles. Crossing the tick is the entire state change and it is never carried by color: as the animated boundary sweeps past the tick's x-position, the solid stroke's `stroke-width` steps 1px -> 2px (itself CSS-transitioned over 200ms) and the printed numerals' `font-weight` steps 400 -> 600 in the same beat — the rule visibly thickens and the digits visibly firm up exactly as the boundary crosses the mark, then holds that state at rest; nothing turns red, nothing recolors, the line just asserts itself harder ('clears its throat'). The rest-state crossing test (used for ARIA and for which side of the 400/600 step the component settles on) is computed from the true `value`/`max` props directly, decoupled from the animation's current frame, so assistive tech never races a mid-flight visual. Structurally the meter is `role=meter` on the wrapper around the SVG, `aria-labelledby` pointing at the visible label span, `aria-valuemin=0`, `aria-valuemax` = max, `aria-valuenow` = value, and `aria-valuetext` spelling the same reading in full words with the crossing state named explicitly — e.g. '38.2 of 100 gigabytes, below warning threshold' or '19 of 20 seats, at or above warning threshold' — built from an optional `unitLabel` prop (falls back to the short `unit` if omitted) so the short glyph used in the printed row ('GB') and the spoken word used by ARIA ('gigabytes') can differ. The SVG itself is `aria-hidden` — decorative once the meter node and the always-visible printed text carry the real reading — and a visually-hidden `role=status`/`aria-live=polite` paragraph mirrors the same `aria-valuetext` so a value change announces without needing focus, matching the printed text as the primary reading for sighted and screen-reader users alike. The component is non-interactive by design: no button, no input, no keyboard surface, nothing for Tab to reach, and it is correctly exempt from the registry's tab-reachability check as a display-only meter. Under `prefers-reduced-motion` (checked via `matchMedia` with a change listener) the boundary tween is skipped entirely — value changes apply their new resting position in a single frame — and the stroke-width/font-weight CSS transitions are stripped via a matching media-query block, so the crossing state is still fully legible, just instant rather than swept. Every ink comes from `stroke-current`/`text-*` token classes (--foreground, --border, --muted) except the tick, which is the one and only place `var(--warning, #f5a623)` appears, exactly per the semantic-color rule that warning marks a threshold marker, never a fill. Differs from sparkline-automaton, which threads a whole SERIES of points into a typographic rule as a KPI sparkline with history and a Wolfram-rule cellular-automaton texture; meter-quota-rule carries no history and no trend at all — it is a single static fraction-of-allowance, legible instantly because the numbers are always printed, meant to appear dozens of times on one settings page (storage, seats, API credits, budget) at plain text scale rather than as a hero chart.

Tags
meterquotausagetypographysvgstatusaccessibilitysettings