Skip to main content

ns-ui

Meter Quota Rule

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.

Usage

registry/core/meter-quota-rule/demo.tsx
"use client";

import { useState } from "react";
import { RationRule } from "./component";

// a settings-page workspace panel: the kind of screen where a quota meter
// like this appears half a dozen times, quietly, at text scale — no bars,
// no pills, just the number and a rule underneath it.
export default function RationRuleDemo() {
  const [storage, setStorage] = useState(38.2);
  const [seats, setSeats] = useState(14);
  const [credits, setCredits] = useState(4200);
  const [budget, setBudget] = useState(2150);

  function simulateUsage() {
    setStorage((v) => (v <= 60 ? 92.4 : 38.2));
    setSeats((v) => (v <= 16 ? 19 : 14));
    setCredits((v) => (v <= 4500 ? 4820 : 4200));
    setBudget((v) => (v <= 3000 ? 4680 : 2150));
  }

  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-md">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / meter-quota-rule
        </p>
        <h1 className="text-lg font-semibold text-foreground">
          Workspace usage
        </h1>
        <p className="mt-1 text-sm leading-relaxed text-ns-muted">
          Every allowance on this page reads the same way: the number, then
          the rule underneath it. Used ground is solid, remaining ground is
          dashed, and a tick marks the warning line — crossing it thickens
          the rule and bolds the digits, never turns them red.
        </p>

        <div className="mt-5 divide-y divide-border rounded-md border border-border bg-surface">
          <div className="p-5">
            <RationRule
              label="Storage"
              value={storage}
              max={100}
              unit="GB"
              unitLabel="gigabytes"
            />
          </div>
          <div className="p-5">
            <RationRule
              label="Seats"
              value={seats}
              max={20}
              unit="seats"
              unitLabel="seats"
            />
          </div>
          <div className="p-5">
            <RationRule
              label="API credits"
              value={credits}
              max={5000}
              unit="req"
              unitLabel="requests"
            />
          </div>
          <div className="p-5">
            <RationRule
              label="Monthly budget"
              value={budget}
              max={5000}
              unit="$"
              unitLabel="dollars"
              warning={0.85}
            />
          </div>
          <div className="flex items-center justify-between gap-4 px-5 py-3">
            <span className="font-mono text-[11px] text-ns-muted">
              advance the billing period
            </span>
            <button
              type="button"
              data-ration-cycle
              onClick={simulateUsage}
              className="rounded-sm border border-border px-3 py-1.5 font-mono text-[11px] tracking-widest text-ns-muted transition-colors duration-200 hover:border-foreground/20 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
            >
              SIMULATE USAGE
            </button>
          </div>
        </div>

        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          the printed numbers are the whole reading — the rule is a footnote
          to them, not the other way around
        </p>
      </div>
    </main>
  );
}

Component

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;
  /** extra classes merged onto the rendered root element */
  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-ns-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-ns-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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/meter-quota-rule.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/meter-quota-rule.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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 --ns-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, --ns-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.

Props

PropTypeDefaultDescription
labelstring—what's being rationed, e.g. "Storage" — shown as a caption and used as the meter's accessible name
valuenumber—amount currently used
maxnumber—total allowance
unitstring—short unit printed after both numbers, e.g. "GB"
unitLabel?string—full unit word spoken by aria-valuetext, e.g. "gigabytes" — defaults to `unit`
warning?number0.8fraction of max (0-1) at which the rule crosses into warning weight
className?string—extra classes merged onto the rendered root element