ns-ui
Meter Quota Meniscus
A quota (storage, API budget, seats) rendered as liquid in a thin vessel where surface curvature, not level, carries the reading: concave under the soft limit, flat at it, convex and overfull-but-held in the grace zone, and a bead that breaks off past the hard limit leaving a permanent stain.
Use when a soft/hard-limit quota (storage, an API budget, seat count) where 'over the soft limit but still tolerated' needs its own visible shape rather than an amber bar segment — curvature reads the zone from a single still frame, and the hard-limit crossing leaves a permanent stain mark rather than just flipping a color. Pick password-strength-tide instead when the reading is a live strength/quality score entered by typing, where liquid LEVEL climbing a canvas tank is the whole mechanism and there is no fixed cap to spill past; pick feeler-gap instead for a single go/no-go threshold check (one value vs one limit, pass or jam) with no intermediate tolerated-overage zone.
Install
npx shadcn add https://design.helpmarq.com /r/meter-quota-meniscus.jsonSource
registry/core/meter-quota-meniscus/component.tsx"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// MeniscusMeter — a quota (storage, API budget, seats) rendered as liquid in
// a thin open-top vessel, where the fill's top-edge CURVATURE, not its level,
// carries the reading. Under the soft limit the level rises normally and the
// edge is concave (wets the walls, dips at center — climbs higher at the
// walls than the middle, same physical tell as water in a narrow glass tube).
// Exactly at the soft limit it flattens. Between soft and hard the level
// pins at the rim and the edge inverts to convex, bulging progressively
// higher above the rim as the value approaches the hard cap — visibly
// overfull, still held by surface tension, no color change required to read
// "tolerated but over." The instant value crosses the hard limit a bead
// detaches from the outer wall and eases down under gravity, then a static
// 1px --ns-muted stain remains — the crossing becomes a permanent mark, not
// just a momentary state. The whole shape is one SVG cubic bezier: fixed
// endpoints at the current level, two shared control points whose y-offset
// from that level is the single scalar that walks concave -> flat -> convex.
// A CSS transition on `d` with a back-out (overshoot) timing function is the
// "soft spring" — one wobble past the target, then it settles, no JS
// simulation loop needed. `role=meter` carries the real semantics (SVG is
// aria-hidden); a Geist Mono numeric readout beside the vessel means
// curvature is never the only signal. Every stroke/fill is a token-driven
// Tailwind class (text-border/text-ns-muted/text-foreground/text-ns-accent) at
// low opacity — no hex, no canvas, no getComputedStyle needed since nothing
// here is a raster surface.
// ---------------------------------------------------------------------------
export interface MeniscusMeterProps {
/** current usage, same unit as softLimit/hardLimit */
value: number;
/** the soft threshold — the vessel's rim; reaching it flattens the surface */
softLimit: number;
/** the hard cap — crossing it detaches a bead and leaves a stain */
hardLimit: number;
/** unit suffix for every readout, e.g. "%", "GB", "seats" (default "%") */
unit?: string;
/** what's being metered, shown above the vessel, e.g. "Storage" */
label?: string;
/** extra classes merged onto the rendered root element */
className?: string;
}
type Zone = "headroom" | "grace" | "spill";
// vessel geometry, SVG viewBox units
const VIEW_W = 96;
const VIEW_H = 168;
const WALL_L = 30;
const WALL_R = 66;
const RIM_Y = 34;
const FLOOR_Y = 142;
const MAX_CONCAVE = 9; // control-point dip at value = 0 (deep headroom)
const MAX_CONVEX = 20; // control-point rise at value = hardLimit (max dome)
// a two-control-point cubic's midpoint sits at 0.25*end + 0.75*control, so a
// concave dip of MAX_CONCAVE bows the visible trough ~0.75*MAX_CONCAVE below
// the wall-anchor point — reserve that much clearance above FLOOR_Y so an
// empty vessel's dipped meniscus never renders below the drawn floor line.
const LEVEL_FLOOR_Y = FLOOR_Y - Math.ceil(MAX_CONCAVE * 0.75) - 3;
const BEAD_X = WALL_R + 4;
const DRIP_LEN = 46; // stain length below the rim
function clamp(n: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, n));
}
function fmt(n: number) {
if (!Number.isFinite(n)) return "0";
const abs = Math.abs(n);
if (Number.isInteger(n) || abs >= 100) return Math.round(n).toString();
return n.toFixed(1);
}
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;
}
function zoneAnnounce(zone: Zone) {
if (zone === "spill") return "over hard limit — overflowing";
if (zone === "grace") return "over soft limit — still held";
return "within soft limit";
}
function zoneShort(zone: Zone) {
if (zone === "spill") return "over hard limit";
if (zone === "grace") return "over soft limit";
return "under soft limit";
}
function surface(value: number, softLimit: number, hardLimit: number) {
const soft = Math.max(1e-6, softLimit);
const hard = Math.max(soft + 1e-6, hardLimit);
let levelY: number;
let curveOffset: number; // positive = convex dome, negative = concave dip
let zone: Zone;
if (value <= soft) {
const t = clamp(value / soft, 0, 1); // 0 empty -> 1 at rim
levelY = LEVEL_FLOOR_Y - t * (LEVEL_FLOOR_Y - RIM_Y);
curveOffset = -MAX_CONCAVE * (1 - t);
zone = "headroom";
} else {
levelY = RIM_Y; // pinned — the level itself never rises further
const t = clamp((value - soft) / (hard - soft), 0, 1);
curveOffset = MAX_CONVEX * t;
zone = value >= hard ? "spill" : "grace";
}
return { levelY, curveOffset, zone };
}
function surfacePath(levelY: number, curveOffset: number) {
const cpY = levelY - curveOffset;
const cp1x = WALL_L + (WALL_R - WALL_L) * 0.33;
const cp2x = WALL_L + (WALL_R - WALL_L) * 0.67;
return `M ${WALL_L} ${levelY} C ${cp1x} ${cpY} ${cp2x} ${cpY} ${WALL_R} ${levelY}`;
}
function fillPath(levelY: number, curveOffset: number) {
return `${surfacePath(levelY, curveOffset)} L ${WALL_R} ${FLOOR_Y} L ${WALL_L} ${FLOOR_Y} Z`;
}
export function MeniscusMeter({
value,
softLimit,
hardLimit,
unit = "%",
label = "Usage",
className = "",
}: MeniscusMeterProps) {
const uid = useId();
const reduced = useReducedMotion();
const { levelY, curveOffset, zone } = useMemo(
() => surface(value, softLimit, hardLimit),
[value, softLimit, hardLimit]
);
const prevZoneRef = useRef<Zone>(zone);
const [stained, setStained] = useState(zone === "spill");
const [beadTick, setBeadTick] = useState(0);
const [announce, setAnnounce] = useState(() => zoneAnnounce(zone));
useEffect(() => {
const prev = prevZoneRef.current;
if (prev !== zone) {
setAnnounce(zoneAnnounce(zone));
if (zone === "spill") {
setStained(true);
if (prev !== "spill") setBeadTick((k) => k + 1);
} else if (zone === "headroom") {
setStained(false);
}
prevZoneRef.current = zone;
}
}, [zone]);
const labelId = `${uid}-label`;
const liveId = `${uid}-live`;
const unitSuffix = unit === "%" ? "%" : unit ? ` ${unit}` : "";
const valueText = `${fmt(value)}${unitSuffix}, ${zoneShort(zone)}`;
const d = fillPath(levelY, curveOffset);
const surfaceD = surfacePath(levelY, curveOffset);
return (
<div className={className}>
<style>{`
.ns-meniscus-fill,.ns-meniscus-surface{transition:d 700ms cubic-bezier(0.34,1.56,0.64,1)}
@keyframes ns-meniscus-drip{
0%{transform:translateY(0);opacity:1}
70%{opacity:1}
100%{transform:translateY(${DRIP_LEN}px);opacity:0}
}
.ns-meniscus-bead{animation:ns-meniscus-drip 900ms cubic-bezier(0.55,0,0.85,0.35) 1}
@media (prefers-reduced-motion: reduce){
.ns-meniscus-fill,.ns-meniscus-surface{transition-duration:140ms;transition-timing-function:linear}
.ns-meniscus-bead{animation:none}
}
`}</style>
<div className="flex items-baseline justify-between gap-3">
<span id={labelId} className="font-mono text-[11px] tracking-wide text-ns-muted">
{label.toUpperCase()}
</span>
<span
className={
"font-mono text-[11px] tracking-wide text-foreground " +
(zone !== "headroom" ? "font-semibold" : "")
}
>
{zoneShort(zone)}
</span>
</div>
<div className="mt-1 flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tabular-nums text-foreground">
{fmt(value)}
{unit === "%" ? "%" : ""}
</span>
<span className="font-mono text-xs text-ns-muted">
/ {fmt(hardLimit)}
{unit !== "%" ? ` ${unit}` : ""} cap
</span>
</div>
<div
role="meter"
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={hardLimit}
aria-valuenow={value}
aria-valuetext={valueText}
className="mt-3 flex justify-center"
>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="h-[168px] w-[96px]"
aria-hidden
focusable="false"
>
{/* floor */}
<line
x1={WALL_L}
x2={WALL_R}
y1={FLOOR_Y}
y2={FLOOR_Y}
className="stroke-current text-border"
strokeWidth={1.5}
/>
{/* liquid body, fill under the meniscus curve */}
<path d={d} className="ns-meniscus-fill fill-current text-foreground opacity-[0.14]" />
{/* surface line, more opaque than the body fill */}
<path
d={surfaceD}
fill="none"
className="ns-meniscus-surface stroke-current text-foreground"
strokeWidth={1.5}
strokeLinecap="round"
/>
{/* walls, stop exactly at the rim so a grace-zone dome visibly rises
into open space above them, unconfined */}
<line
x1={WALL_L}
x2={WALL_L}
y1={RIM_Y}
y2={FLOOR_Y}
className="stroke-current text-border"
strokeWidth={2}
/>
<line
x1={WALL_R}
x2={WALL_R}
y1={RIM_Y}
y2={FLOOR_Y}
className="stroke-current text-border"
strokeWidth={2}
/>
{/* rim lip ticks */}
<line
x1={WALL_L - 3}
x2={WALL_L + 3}
y1={RIM_Y}
y2={RIM_Y}
className="stroke-current text-border"
strokeWidth={1.5}
/>
<line
x1={WALL_R - 3}
x2={WALL_R + 3}
y1={RIM_Y}
y2={RIM_Y}
className="stroke-current text-border"
strokeWidth={1.5}
/>
{/* stain — static, left by the hard-limit crossing */}
{stained ? (
<line
x1={BEAD_X}
x2={BEAD_X}
y1={RIM_Y}
y2={RIM_Y + DRIP_LEN}
className="stroke-current text-ns-muted opacity-60"
strokeWidth={1}
/>
) : null}
{/* bead — one-shot drop animation replayed via key on each crossing */}
{!reduced && beadTick > 0 ? (
<circle
key={beadTick}
cx={BEAD_X}
cy={RIM_Y}
r={2.25}
className="ns-meniscus-bead fill-current text-ns-muted"
/>
) : null}
</svg>
</div>
<p className="mt-2 text-center font-mono text-[11px] text-ns-muted">
{zone === "spill"
? "held — overflow marked on the glass"
: zone === "grace"
? "over the soft limit, still tolerated"
: `${fmt(Math.max(0, softLimit - value))}${unitSuffix} of headroom left`}
</p>
<span id={liveId} role="status" aria-live="polite" className="sr-only">
{announce}
</span>
</div>
);
}
Build spec
Renders one quota reading — `value` against a `softLimit` and a `hardLimit`, all in the same `unit` (default "%") — as liquid in an open-top SVG vessel (two vertical wall lines plus a floor line, all `stroke-current text-border`) whose fill's top edge is a single cubic bezier: fixed endpoints at the current `levelY`, two control points sharing one `curveOffset` scalar that is the entire mechanism. Below `softLimit`, `levelY` rises linearly from the floor toward the rim as `value` goes 0 -> softLimit, and `curveOffset` is negative and shrinking toward zero — control points sit below the endpoints, pulling the middle of the curve down so the edges read higher than the center: a concave meniscus wetting the walls, deepest at value=0, flattening as `value` approaches `softLimit`. At `softLimit` the curve is exactly flat (`curveOffset` = 0) and `levelY` reaches the rim exactly. Between `softLimit` and `hardLimit`, `levelY` stops rising — pinned at the rim, because the vessel is nominally full — and `curveOffset` instead goes positive and grows toward `hardLimit`: control points rise above the endpoints, bulging the middle upward past the two wall-line tops (which stop exactly at the rim y, leaving open space above them) into a visible convex dome, unconfined by any wall above that height — over budget, physically overfull, still held by the curve, no color change required to read the zone. The instant `value` reaches or passes `hardLimit`, `curveOffset` clamps at its maximum (the dome stays pinned at its fullest rather than growing further) and, edge-triggered on that specific crossing (a ref tracks the previous zone so re-renders at the same over-hard value don't replay it), a small `text-ns-muted` circle appears at a fixed point just outside the right wall and runs one `900ms` gravity-eased (`cubic-bezier(0.55,0,0.85,0.35)`, i.e. accelerating) keyframe animation down the outside of the glass, fading out as it lands; immediately behind it a static `1px` `text-ns-muted` line at 60% opacity remains from the rim down a fixed drip length — the stain, a permanent record of the crossing that persists through further value changes and only clears once `value` drops back to a clear headroom read (`<= softLimit`), not merely below `hardLimit`, so a value hovering just under the hard cap after a spill still shows the mark. Both `d`-valued SVG paths (the filled liquid body and the stroked surface line) share one CSS `transition: d 700ms cubic-bezier(0.34,1.56,0.64,1)` — a back-out timing function whose control points push the eased fraction past 1 before settling back to it, which is the 'soft spring': no JS simulation loop, the curve genuinely overshoots its new target once and stills on every `value`, `softLimit`, or `hardLimit` change. A Geist Mono readout sits above the vessel: the metered label and a bold zone chip ('under soft limit' / 'over soft limit' / 'over hard limit') on one line, the live value (with a trailing '%' when `unit` is '%', a space-prefixed unit otherwise) and '/ {hardLimit} cap' on the next, and a status caption below the vessel spelling out remaining headroom, 'over the soft limit, still tolerated', or 'held — overflow marked on the glass' depending on zone — so the reading is never dependent on perceiving curvature alone. The outer element is `role=meter` with `aria-labelledby` on the visible label, `aria-valuemin=0`, `aria-valuemax=hardLimit`, `aria-valuenow=value`, and `aria-valuetext` spelling e.g. '84%, over soft limit' (value, unit suffix, zone) — the exact sentence a screen reader gets on every read, not just at a boundary crossing. A separate visually-hidden `role=status`/`aria-live=polite` span announces a fuller zone-change sentence ('over soft limit — still held', 'over hard limit — overflowing', 'within soft limit') only when the zone itself changes, not on every value tick, so assistive tech isn't spammed mid-fill. The SVG itself is `aria-hidden` — decorative once the meter node and the two text readouts carry the real semantics — and being a passive display component with no exposed control, it is correctly exempt from Tab-reachability on its own (the demo's cycle button is what Tab actually reaches). Under `prefers-reduced-motion: reduce` (checked via `matchMedia` with a live change listener) the bead's keyframe animation is stripped entirely via a media-query CSS block (the stain still appears, just without the drop), but the `d` transition is shortened to a plain 140ms linear ease rather than removed — curvature IS the reading, so a value/zone change still visibly settles into its new shape instead of teleporting there, it just loses the back-out spring overshoot. Every stroke and fill is a `stroke-current`/`fill-current` Tailwind class tinted `text-border`, `text-ns-muted`, or `text-foreground` at low opacity — never a hex literal or `getComputedStyle` read, since nothing here is a raster surface — so both themes restyle for free. No canvas: two SVG `<path>` elements, three `<line>` walls/floor/lip-ticks, one conditional stain `<line>`, and one conditional bead `<circle>` are the entire visual, DOM+SVG+CSS only.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | current usage, same unit as softLimit/hardLimit |
| softLimit | number | — | the soft threshold — the vessel's rim; reaching it flattens the surface |
| hardLimit | number | — | the hard cap — crossing it detaches a bead and leaves a stain |
| unit? | string | "%" | unit suffix for every readout, e.g. "%", "GB", "seats" (default "%") |
| label? | string | "Usage" | what's being metered, shown above the vessel, e.g. "Storage" |
| className? | string | — | extra classes merged onto the rendered root element |