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