Skip to main content

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

Source
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

PropTypeDefaultDescription
valuenumbercurrent usage, same unit as softLimit/hardLimit
softLimitnumberthe soft threshold — the vessel's rim; reaching it flattens the surface
hardLimitnumberthe 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?stringextra classes merged onto the rendered root element