Skip to main content

ns-ui

Meter Threshold Trip

A data-driven threshold indicator built like a thermostat's bimetallic strip: it bows progressively as a metric climbs, snaps across a visible contact gap at the trip point and latches, then only re-straightens once the value falls below a lower re-arm mark, making hysteresis visible instead of implying it with a color change.

Usage

registry/core/meter-threshold-trip/demo.tsx
"use client";

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

// a realistic incident-response metric walked through one full trip/re-arm
// cycle: comfortably clear, climbing toward the trip point, tripped and
// latched, then dipping back but not far enough to clear, then finally
// falling below the re-arm mark. The gap between "trips" and "clears" is
// what makes the alert's hysteresis visible instead of implied by color.
const STEPS = [45, 72, 88, 74, 55];

export default function BimetalTripDemo() {
  const [step, setStep] = useState(0);
  const value = STEPS[step % STEPS.length] ?? 45;

  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-threshold-trip
        </p>
        <h1 className="text-lg font-semibold text-foreground">
          Queue latency alert
        </h1>
        <p className="mt-1 text-sm leading-relaxed text-ns-muted">
          The strip bows as latency climbs and snaps against the contact the
          instant it trips — then stays latched, unmoved by a value that
          only dips partway back. It only re-arms once latency falls all the
          way below the lower clear mark.
        </p>

        <div className="mt-5 rounded-md border border-border bg-surface p-5">
          <BimetalTrip
            label="p99 latency"
            value={value}
            tripAt={80}
            clearAt={65}
            unit="ms"
          />
        </div>

        <div className="mt-4 flex items-center justify-between gap-4 rounded-md border border-border px-5 py-3">
          <span className="font-mono text-[11px] text-ns-muted">
            step the next latency sample
          </span>
          <button
            type="button"
            data-bimetal-cycle
            onClick={() => setStep((s) => s + 1)}
            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"
          >
            NEXT
          </button>
        </div>

        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          latched ≠ still over trip — it stays latched until the value clears
          the lower mark, not just drops below the trip point
        </p>
      </div>
    </main>
  );
}

Component

registry/core/meter-threshold-trip/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// BimetalTrip — a data-driven threshold indicator built like a thermostat's
// bimetallic strip. One SVG quadratic-bezier path is fixed at two mounting
// posts; only its control point moves, offset upward as `value` climbs
// toward `tripAt` — straight at the bottom of the range, closing in on (but
// deliberately never quite touching) the contact pad as it nears the trip
// point, so there is always a real gap left for the latch to snap shut. That
// crossing LATCHES: the strip stays pinned at full bow (heavier --foreground
// stroke, filled contact pad — never
// --ns-accent, latching is data state, not interaction) regardless of value
// wobbling anywhere between `clearAt` and `tripAt`. It only relaxes back
// once value falls below `clearAt`, the lower re-arm mark — that gap between
// where it trips and where it clears is hysteresis made visible, not implied
// by a color. The snap into contact is a 320ms CSS `d`-transition on a
// back-out curve (~12% overshoot, no JS spring loop); the re-arm relax is a
// slow 600ms ease-out-expo settle; ordinary climbing between ticks gets a
// quick, non-overshooting ease. A muted hairline band under the strip spans
// clearAt..tripAt on the same x-domain as the strip's own mounts, with a
// live position marker and Geist Mono 'clears N / trips N' captions, so the
// two thresholds are legible from a single still frame. Every stroke/fill is
// a token class (text-foreground/text-ns-muted/text-border) — no hex, no
// canvas. DOM+SVG+CSS only.
// ---------------------------------------------------------------------------

export interface BimetalTripProps {
  /** the live metric being watched */
  value: number;
  /** the upper threshold — crossing it latches the strip against the contact */
  tripAt: number;
  /** the lower re-arm mark — the strip only relaxes once value falls below this */
  clearAt: number;
  /** domain floor for the visual scale (default 0) */
  min?: number;
  /** domain ceiling for the visual scale (default 100) */
  max?: number;
  /** unit suffix for readouts, e.g. "%", "ms", "req/s" (default "%") */
  unit?: string;
  /** what's being watched, shown above the strip, e.g. "CPU temp" */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

// geometry, SVG viewBox units
const VIEW_W = 320;
const VIEW_H = 150;
const ANCHOR_L_X = 40;
const ANCHOR_R_X = 280;
const MID_X = (ANCHOR_L_X + ANCHOR_R_X) / 2;
const BASE_Y = 62;
const MAX_CONTROL_OFFSET = 46; // control-point y-offset at full bow (bowFrac 1)
const PEAK_OFFSET = MAX_CONTROL_OFFSET / 2; // a quadratic's true peak sits at half the control offset
const CONTACT_Y = BASE_Y - PEAK_OFFSET; // where the strip's tip meets the pad, fully latched
const POST_TOP_Y = CONTACT_Y - 18;
const PAD_W = 16;
const PAD_H = 5;
const BAND_Y = 112;
const BAND_H = 6;
const TICK_H = 10;
// the unlatched approach curve's ceiling — kept below 1 (full/latched bow) so
// there is always a real geometric gap left against the contact pad for the
// latch's snap to close; see the bowFrac comment below for why this matters.
const UNLATCHED_BOW_CEILING = 0.75;

type TransitionKind = "climb" | "snap" | "rearm";

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 transitionStyle(kind: TransitionKind, reduced: boolean) {
  // The strip's curvature IS the reading (latched vs. clear) — a 0ms jump
  // removes the only passive signal that value crossed a threshold. Keep a
  // short, non-overshooting linear settle instead of the full snap/rearm
  // spring curves.
  if (reduced) return { transitionDuration: "140ms", transitionTimingFunction: "linear" };
  if (kind === "snap") {
    return {
      transitionDuration: "320ms",
      transitionTimingFunction: "cubic-bezier(0.34, 1.56, 0.64, 1)",
    };
  }
  if (kind === "rearm") {
    return {
      transitionDuration: "600ms",
      transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
    };
  }
  return {
    transitionDuration: "260ms",
    transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
  };
}

export function BimetalTrip({
  value,
  tripAt,
  clearAt,
  min = 0,
  max = 100,
  unit = "%",
  label = "Threshold",
  className = "",
}: BimetalTripProps) {
  const uid = useId();
  const reduced = useReducedMotion();

  // latched is derived data state, initialized once from the mounting value
  // so a demo/dashboard that mounts already past tripAt renders latched with
  // no spurious snap animation on first paint.
  const [latched, setLatched] = useState(() => value >= tripAt);
  const [transitionKind, setTransitionKind] = useState<TransitionKind>("climb");
  const [announce, setAnnounce] = useState("");
  const mountedRef = useRef(false);

  useEffect(() => {
    if (!mountedRef.current) {
      mountedRef.current = true;
      return;
    }
    if (!latched && value >= tripAt) {
      setLatched(true);
      setTransitionKind("snap");
      setAnnounce(
        `Latched — ${fmt(value)}${unit} crossed trip at ${fmt(tripAt)}${unit}. Holds until it falls below ${fmt(clearAt)}${unit}.`
      );
    } else if (latched && value < clearAt) {
      setLatched(false);
      setTransitionKind("rearm");
      setAnnounce(`Re-armed — ${fmt(value)}${unit} fell below clear mark ${fmt(clearAt)}${unit}.`);
    } else {
      setTransitionKind("climb");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value, tripAt, clearAt]);

  // BUG FIX: bowFrac must never let the *unlatched* approach curve reach the
  // same numeric value as the *latched* pinned bow (1). It used to — clamp()
  // saturated the unlatched formula to exactly 1 the instant value reached
  // tripAt, which is the same value the latch pins to, so by the time the
  // `latched` effect actually flipped (one tick later) the path's `d` hadn't
  // changed at all: no delta for the CSS transition to animate, so the
  // signature 320ms back-out "snap into contact" never played — the strip
  // just silently arrived already fully bowed. Capping the unlatched
  // approach short of 1 keeps a real, persistent gap against the contact pad
  // (matching "closes a visible gap" below) so crossing tripAt always leaves
  // genuine distance for the snap to close, regardless of step size.
  const safeTripAt = tripAt > min ? tripAt : min + 1e-6;
  const approachFrac = clamp((value - min) / (safeTripAt - min), 0, 1);
  const bowFrac = latched ? 1 : approachFrac * UNLATCHED_BOW_CEILING;
  const controlY = BASE_Y - bowFrac * MAX_CONTROL_OFFSET;
  const stripD = `M ${ANCHOR_L_X} ${BASE_Y} Q ${MID_X} ${controlY} ${ANCHOR_R_X} ${BASE_Y}`;
  const stripStyle = transitionStyle(transitionKind, reduced);

  const domain = Math.max(1e-6, max - min);
  const xFor = (v: number) => ANCHOR_L_X + (clamp(v, min, max) - min) / domain * (ANCHOR_R_X - ANCHOR_L_X);
  const xClear = xFor(clearAt);
  const xTrip = xFor(tripAt);
  const xValue = xFor(value);

  const labelId = `${uid}-label`;
  const descId = `${uid}-desc`;
  const liveId = `${uid}-live`;
  const valueText = `${fmt(value)}${unit}, ${latched ? "latched" : "clear"}, trips at ${fmt(tripAt)}${unit}, clears at ${fmt(clearAt)}${unit}`;
  const stateWord = latched ? "LATCHED" : "CLEAR";
  const description = latched
    ? `Latched at ${fmt(value)}${unit} — trips at ${fmt(tripAt)}${unit}, will re-arm below ${fmt(clearAt)}${unit}.`
    : `Clear at ${fmt(value)}${unit} — trips at ${fmt(tripAt)}${unit}, clears at ${fmt(clearAt)}${unit}.`;

  return (
    <div className={className}>
      <style>{`
.ns-bimetal-strip{transition-property:d}
@media (prefers-reduced-motion: reduce){
  .ns-bimetal-strip{transition-duration:140ms !important;transition-timing-function:linear !important}
}
`}</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 " +
            (latched ? "font-semibold" : "")
          }
        >
          {stateWord}
        </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>
      </div>

      <div
        role="meter"
        aria-labelledby={labelId}
        aria-describedby={descId}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={clamp(value, min, max)}
        aria-valuetext={valueText}
        tabIndex={0}
        className="mt-3 rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent"
      >
        <svg
          viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
          className="h-[150px] w-full"
          aria-hidden
          focusable="false"
        >
          {/* hysteresis band, on the strip's own x-domain */}
          <rect
            x={xClear}
            y={BAND_Y}
            width={Math.max(0, xTrip - xClear)}
            height={BAND_H}
            rx={2}
            className="fill-current text-ns-muted opacity-40"
          />
          <line
            x1={xClear}
            x2={xClear}
            y1={BAND_Y - TICK_H / 2}
            y2={BAND_Y + BAND_H + TICK_H / 2}
            className="stroke-current text-border"
            strokeWidth={1}
          />
          <line
            x1={xTrip}
            x2={xTrip}
            y1={BAND_Y - TICK_H / 2}
            y2={BAND_Y + BAND_H + TICK_H / 2}
            className="stroke-current text-border"
            strokeWidth={1}
          />
          {/* live position marker along the band */}
          <circle
            cx={xValue}
            cy={BAND_Y + BAND_H / 2}
            r={3}
            className="fill-current text-foreground"
          />

          {/* mounting posts, fixed endpoints of the strip */}
          <rect
            x={ANCHOR_L_X - 4}
            y={BASE_Y - 9}
            width={8}
            height={18}
            rx={2}
            className="fill-current text-border"
          />
          <rect
            x={ANCHOR_R_X - 4}
            y={BASE_Y - 9}
            width={8}
            height={18}
            rx={2}
            className="fill-current text-border"
          />

          {/* the strip itself — one quadratic bezier, control point is the mechanism */}
          <path
            d={stripD}
            fill="none"
            className="ns-bimetal-strip stroke-current text-foreground"
            style={stripStyle}
            strokeWidth={latched ? 3.5 : 2}
            strokeLinecap="round"
          />

          {/* contact assembly: fixed post + pad the strip's tip closes against */}
          <line
            x1={MID_X}
            x2={MID_X}
            y1={POST_TOP_Y}
            y2={CONTACT_Y}
            className="stroke-current text-border"
            strokeWidth={1.5}
          />
          <rect
            x={MID_X - PAD_W / 2}
            y={CONTACT_Y - PAD_H / 2}
            width={PAD_W}
            height={PAD_H}
            rx={1.5}
            className={
              latched
                ? "fill-current text-foreground"
                : "fill-none stroke-current text-border"
            }
            strokeWidth={latched ? 0 : 1.5}
          />
          {latched ? (
            <circle
              data-bimetal-contact-lit
              cx={MID_X}
              cy={CONTACT_Y}
              r={4}
              className="fill-current text-foreground"
            />
          ) : null}
        </svg>
      </div>

      <div className="mt-1.5 flex items-center justify-between font-mono text-[11px] text-ns-muted">
        <span>
          clears {fmt(clearAt)}
          {unit}
        </span>
        <span>
          trips {fmt(tripAt)}
          {unit}
        </span>
      </div>

      <p id={descId} className="mt-2 text-center font-mono text-[11px] text-ns-muted">
        {description}
      </p>

      <span id={liveId} role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </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-threshold-trip.tsx, so point the import there.

Use when a dashboard alert with two thresholds — a trip point and a lower re-arm/clear mark — where the reason it hasn't cleared yet (it dipped, but not far enough) needs to be visibly different from simply being over the trip point again; the strip's pinned latch shows that gap directly. Pick switch-frost instead for an ordinary user-flipped binary toggle with no metric behind it, or password-strength-tide for a live score climbing from typed input with no fixed trip/clear pair — meter-threshold-trip has no user control at all, `value` is the only input, and its whole identity is the asymmetric snap-then-relax hysteresis loop.

Install

npx shadcn add https://design.helpmarq.com/r/meter-threshold-trip.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 watched metric — `value` against an upper `tripAt` and a lower `clearAt` (with `clearAt` < `tripAt`), plus a `min`/`max` display domain (default 0/100) and `unit` (default "%") — as a bimetallic thermostat strip instead of a colored status badge. The strip is a single SVG quadratic bezier fixed at two mounting posts (`stroke-current text-border` rects); only its control point moves, offset upward as a `bowFrac` scalar that is the entire mechanism: while unlatched, `bowFrac` = clamp((value-min)/(tripAt-min), 0, 1) scaled to a fixed ceiling short of full bow — 0 (straight) at `min`, approaching but deliberately never reaching full bow as `value` nears `tripAt` — and while latched it is pinned at exactly 1, which is what makes the hysteresis visible (a value that dips from above `tripAt` back down into the band between `clearAt` and `tripAt` does not un-bow the strip at all) and also what guarantees the snap always has real distance to close: the unlatched ceiling and the latched pin are never numerically equal, so the moment `value` reaches `tripAt` there is always a genuine gap left for the crossing to visibly snap shut, regardless of how big a step `value` jumped by. The strip's tip closes a visible gap against a fixed contact pad mounted above it; the instant `value` reaches `tripAt` while unlatched, a `latched` boolean flips true (derived purely from data — a `value`/`tripAt`/`clearAt` effect, never user input) and, edge-triggered on that specific crossing, the path's `d` transitions on a 320ms back-out CSS curve (`cubic-bezier(0.34, 1.56, 0.64, 1)`, i.e. genuinely overshoots past full bow before settling — the ~12% spring overshoot, achieved as a CSS `d` transition rather than a JS simulation loop, matching this registry's established technique for path-level springs) while the strip's `stroke-width` jumps from 2 to 3.5 (a heavier `text-foreground` stroke — never `text-ns-accent`, since latching is a data state, not an interaction) and the contact pad switches from an outlined `text-border` rect to a filled `text-foreground` one with an additional filled circle exactly at the touch point marking contact made. Latched, the strip stays exactly there — pinned, heavier, contact filled — no matter how `value` moves, until it falls below `clearAt`, at which point `latched` flips false and the same `d` instead relaxes on a slow 600ms ease-out-expo curve (`cubic-bezier(0.16, 1, 0.3, 1)`, no overshoot) back down to wherever the ordinary `bowFrac` formula now places it for the current (sub-`clearAt`) value — a deliberate 'un-tensing' rather than a snap. Ordinary value changes that cross neither threshold ease on a quick 260ms non-overshooting curve of the same easing family, so only the two threshold crossings get their own distinct physics. Below the strip, a muted hairline band (`fill-current text-ns-muted opacity-40`) spans `clearAt`..`tripAt` on the exact same x-domain as the strip's own mounting posts, with a `text-border` tick at each end, a small `text-foreground` circle riding along it at the live value's position, and a Geist Mono caption row below reading 'clears {clearAt}{unit}' / 'trips {tripAt}{unit}' — the two marks are legible from a single still frame, not just inferable from strip position. A bold Geist Mono state chip ('CLEAR' / 'LATCHED') sits beside the label above the strip at all times, and a status paragraph below spells out the current state and, when latched, exactly what has to happen to clear it. The outer meter node is `role=meter`, `aria-labelledby` on the visible label, `aria-describedby` on that status paragraph (which always states the trip mark, the clear mark, and the current latch state — legible on demand, not just at the moment of a crossing), `aria-valuemin=min`, `aria-valuemax=max`, `aria-valuenow` (clamped into [min,max]), and `aria-valuetext` spelling the value, latch state, and both thresholds in one sentence; it also carries `tabIndex=0` so it is reachable and inspectable by a screen reader even though it exposes no interactive affordance of its own (not a control, correctly exempt from the registry's accessible-name-on-controls rule, though it has one anyway via `aria-labelledby`). A separate visually-hidden `role=status`/`aria-live=polite` span announces only the two edge-triggered transitions ('Latched — ... crossed trip ...' / 'Re-armed — ... fell below clear ...'), not every value tick. The SVG itself is `aria-hidden`, decorative once the meter node and the two text readouts carry the real semantics. Under `prefers-reduced-motion: reduce` (checked via `matchMedia` with a live change listener, backed by a CSS media-query override on the path's `transition` as well) every `d` transition shortens to a plain 140ms linear ease instead of collapsing to instant — snap, re-arm, and ordinary climbs all still visibly settle into their resting shape rather than teleporting, since the strip's curvature is the only passive signal the threshold was crossed — while the same aria-live announcements still fire on the same crossings, so the state change is never silent even without motion. Every stroke and fill is a `stroke-current`/`fill-current` Tailwind class tinted `text-border`, `text-ns-muted`, or `text-foreground` — never a hex literal, never `getComputedStyle`, since nothing here is a raster surface — so both themes restyle for free. No canvas: one strip `<path>`, two mounting-post `<rect>`s, one contact post `<line>` plus pad `<rect>` plus a conditional lit `<circle>`, one hysteresis band `<rect>` with two tick `<line>`s and a live-position `<circle>`, is the entire SVG, DOM+SVG+CSS only, zero dependencies.

Props

PropTypeDefaultDescription
valuenumber—the live metric being watched
tripAtnumber—the upper threshold — crossing it latches the strip against the contact
clearAtnumber—the lower re-arm mark — the strip only relaxes once value falls below this
min?number0domain floor for the visual scale (default 0)
max?number100domain ceiling for the visual scale (default 100)
unit?string"%"unit suffix for readouts, e.g. "%", "ms", "req/s" (default "%")
label?string"Threshold"what's being watched, shown above the strip, e.g. "CPU temp"
className?string—extra classes merged onto the rendered root element