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.
npx shadcn add https://design.helpmarq.com /r/meter-quota-rule.jsonregistry/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>
);
}
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.
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.