Meter Threshold Trip

Meter

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.

Install
npx shadcn add https://design.helpmarq.com/r/meter-threshold-trip.json
Source
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
// --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-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;
  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) {
  if (reduced) return { transitionDuration: "0ms" };
  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:none !important}
}
`}</style>

      <div className="flex items-baseline justify-between gap-3">
        <span id={labelId} className="font-mono text-[11px] tracking-wide text-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-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-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-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-muted">
        {description}
      </p>

      <span id={liveId} role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}
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.

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-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-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 collapses to instant (0ms) — snap, re-arm, and ordinary climbs all jump straight to their resting shape — 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-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.

Tags
meterthresholdhysteresisalertsvgspringstatusaccessibility