Bounded numeric stepper with a large mono value readout, prominent -/+ buttons, and a labeled history strip: an inertial needle swings to each committed value while the strip advances, etching the last 20 adjustments with the newest mark highlighted.
npx shadcn add https://design.helpmarq.com /r/stepper-needle.jsonregistry/core/stepper-needle/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// NeedleStepper — bounded numeric spinbutton fused to a vertical seismograph
// drum. A weighted inertial needle (spring k=120 s^-2, zeta=0.45, two visible
// oscillations, forced-settle 900 ms) swings across a 64px paper strip to
// each committed value; every commit advances the strip 26px downward over
// 600 ms ease-out (event-driven, loop sleeps between) and etches a pen trace
// of the last 20 adjustments as a pruned polyline, alpha fading 0.9 -> 0.2
// by age index with full clears each frame (no destination-in); the newest
// etch mark is highlighted (solid dot + halo) and the strip carries a
// "history" label. An --error edge band appears only while the value sits at
// min/max; clamping fires a hard-stop quiver (±2px, 2 cycles, 180 ms). The
// committed value renders as a large mono hero input flanked by prominent
// -/+ buttons. Direct-DOM rAF, no React state on the hot
// path; all drum ink getComputedStyle-derived at mount and re-derived on
// documentElement class flips. prefers-reduced-motion: instant needle jumps,
// static trace, no strip animation.
// ---------------------------------------------------------------------------
const PEN_Y = 18; // pen carriage line, px from strip top
const PAD_X = 10; // needle travel inset == bound-band width
const ADVANCE = 26; // strip feed per committed adjustment, px
const SCROLL_MS = 600;
const SPRING_K = 120; // s^-2
const SPRING_ZETA = 0.45;
const SETTLE_MS = 900; // forced-settle deadline
const QUIVER_MS = 180;
const QUIVER_AMP = 2;
const HOLD_DELAY_MS = 400;
const HOLD_INTERVAL_MS = 1000 / 12; // 12 steps/s
const MAX_TRACE = 20;
type Vec3 = readonly [number, number, number];
function parseColor(raw: string): Vec3 | null {
const s = raw.trim();
if (s.startsWith("#")) {
const hex = s.slice(1);
if (hex.length === 3) {
const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
return null;
}
const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
const rgba = (c: Vec3, a: number) =>
`rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;
interface TracePoint {
v: number; // committed value — x recomputed at draw time (resize-safe)
s: number; // strip-space position, ADVANCE * commit index
}
export function NeedleStepper({
value,
defaultValue = 20,
min = 0,
max = 100,
step = 1,
label = "Value",
unit,
onValueChange,
disabled = false,
className = "",
}: {
/** controlled value; omit for uncontrolled */
value?: number;
defaultValue?: number;
min?: number;
max?: number;
step?: number;
/** accessible name for the spinbutton */
label?: string;
/** unit suffix rendered beside the value, e.g. "°C" */
unit?: string;
onValueChange?: (value: number) => void;
disabled?: boolean;
className?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const drumRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const engineRef = useRef<{
commit: (v: number, clamped: boolean) => void;
redraw: () => void;
} | null>(null);
const decimals = (() => {
const frac = String(step).split(".")[1];
return frac ? frac.length : 0;
})();
const clampRound = (v: number) =>
Number(Math.min(max, Math.max(min, v)).toFixed(decimals));
const isControlled = value !== undefined;
const [internal, setInternal] = useState(() => clampRound(defaultValue));
const current = isControlled ? clampRound(value) : internal;
const [text, setText] = useState(() => String(current));
// mirrors :hover/:active into data-attrs so the site's synthetic pointer
// driver (isTrusted: false) can still demonstrate button feedback —
// Chromium never flips real pseudo-classes for script-dispatched events.
const [hoverDir, setHoverDir] = useState(0); // -1 | 0 | 1
const [pressDir, setPressDir] = useState(0); // -1 | 0 | 1
const valueRef = useRef(current);
valueRef.current = current;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const boundsRef = useRef({ min, max });
boundsRef.current = { min, max };
const holdRef = useRef<{
timeout: ReturnType<typeof setTimeout> | null;
interval: ReturnType<typeof setInterval> | null;
}>({ timeout: null, interval: null });
// ---- drum engine --------------------------------------------------------
useEffect(() => {
const drum = drumRef.current;
const canvas = canvasRef.current;
const root = rootRef.current;
if (!drum || !canvas || !root) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
// token-derived ink: read at mount, re-derived on theme class change
let fg: Vec3 = [237, 237, 237];
let bd: Vec3 = [46, 46, 46];
let err: Vec3 = [234, 0, 29];
const derive = () => {
const cs = getComputedStyle(document.documentElement);
fg = parseColor(cs.getPropertyValue("--foreground")) ?? fg;
bd = parseColor(cs.getPropertyValue("--border")) ?? bd;
err = parseColor(cs.getPropertyValue("--error")) ?? err;
};
derive();
// hot-path state: locals only, never React state
let w = 0;
let h = 0;
let dpr = 1;
let sized = false;
let raf = 0;
let last = 0;
let visible = true;
let lastV = valueRef.current;
let x = 0; // needle px (assigned real value on first resize)
let vx = 0;
let springOn = false;
let deadline = 0;
let qOff = 0;
let quiverStart = -1;
let commitIdx = 0;
const points: TracePoint[] = [{ v: lastV, s: 0 }];
let scrollCur = 0;
let scrollFrom = 0;
let scrollTarget = 0;
let scrollStart = -1;
const xFor = (v: number) => {
const { min: lo, max: hi } = boundsRef.current;
const span = hi - lo || 1;
return PAD_X + ((v - lo) / span) * (w - PAD_X * 2);
};
const draw = () => {
if (!sized) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h); // full clear — no accumulating alpha
// bound status band (semantic --error, status use only): only drawn on
// the edge actually hit, so red always means "at the limit"
const { min: lo, max: hi } = boundsRef.current;
if (lastV <= lo) {
ctx.fillStyle = rgba(err, 0.16);
ctx.fillRect(0, 0, PAD_X, h);
} else if (lastV >= hi) {
ctx.fillStyle = rgba(err, 0.16);
ctx.fillRect(w - PAD_X, 0, PAD_X, h);
}
// drum feed lines, fixed in strip space so they roll with the paper
ctx.strokeStyle = rgba(bd, 0.45);
ctx.lineWidth = 1;
ctx.beginPath();
const first = ((((PEN_Y + scrollCur) % ADVANCE) + ADVANCE) % ADVANCE);
for (let y = first; y < h; y += ADVANCE) {
ctx.moveTo(0, Math.round(y) + 0.5);
ctx.lineTo(w, Math.round(y) + 0.5);
}
ctx.stroke();
// pen carriage hairline
ctx.strokeStyle = rgba(bd, 0.9);
ctx.beginPath();
ctx.moveTo(0, PEN_Y + 0.5);
ctx.lineTo(w, PEN_Y + 0.5);
ctx.stroke();
// pen trace: pruned polyline, newest -> oldest, alpha by age index
const nx = x + qOff;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 1.75;
let px = nx;
let py = PEN_Y;
const n = points.length;
for (let i = n - 1; i >= 0; i--) {
const p = points[i];
if (!p) continue;
const age = n - 1 - i;
const a = Math.max(0.2, 0.9 - (0.7 * age) / (MAX_TRACE - 1));
const cx = xFor(p.v);
const cy = PEN_Y + (scrollCur - p.s);
ctx.strokeStyle = rgba(fg, a);
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(cx, cy);
ctx.stroke();
ctx.fillStyle = rgba(fg, a);
ctx.beginPath();
ctx.arc(cx, cy, 1.6, 0, Math.PI * 2);
ctx.fill();
px = cx;
py = cy;
if (cy > h + 4) break; // scrolled off the strip — stop early
}
// newest etch mark highlighted: solid dot + halo ring on the pen line
const newest = points[n - 1];
if (newest) {
const hx = xFor(newest.v);
const hy = PEN_Y + (scrollCur - newest.s);
ctx.fillStyle = rgba(fg, 1);
ctx.beginPath();
ctx.arc(hx, hy, 2.4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = rgba(fg, 0.35);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(hx, hy, 4.5, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1.75;
}
// weighted needle: counterweight, stem, tip riding the pen line
ctx.strokeStyle = rgba(fg, 0.9);
ctx.lineWidth = 1.25;
ctx.beginPath();
ctx.moveTo(nx, 3);
ctx.lineTo(nx, PEN_Y - 6);
ctx.stroke();
ctx.fillStyle = rgba(fg, 0.9);
ctx.beginPath();
ctx.arc(nx, 5, 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = rgba(fg, 1);
ctx.beginPath();
ctx.moveTo(nx, PEN_Y);
ctx.lineTo(nx - 3, PEN_Y - 7);
ctx.lineTo(nx + 3, PEN_Y - 7);
ctx.closePath();
ctx.fill();
};
const loop = (now: number) => {
raf = 0;
const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;
last = now;
let active = false;
if (springOn) {
const target = xFor(lastV);
const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
vx += (-SPRING_K * (x - target) - c * vx) * dt;
x += vx * dt;
// velocity-epsilon sleep, backstopped by the forced-settle deadline
if (now >= deadline || (Math.abs(x - target) < 0.05 && Math.abs(vx) < 2)) {
x = target;
vx = 0;
springOn = false;
} else active = true;
}
qOff = 0;
if (quiverStart >= 0) {
const t = (now - quiverStart) / QUIVER_MS;
if (t >= 1) quiverStart = -1;
else {
qOff = QUIVER_AMP * Math.sin(t * Math.PI * 4) * (1 - t); // 2 cycles
active = true;
}
}
if (scrollStart >= 0) {
const t = (now - scrollStart) / SCROLL_MS;
if (t >= 1) {
scrollCur = scrollTarget;
scrollStart = -1;
} else {
const e = 1 - Math.pow(1 - t, 3); // ease-out
scrollCur = scrollFrom + (scrollTarget - scrollFrom) * e;
active = true;
}
}
draw();
if (active && visible && !document.hidden)
raf = requestAnimationFrame(loop);
};
const wake = () => {
if (!raf && visible && !document.hidden) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
const commit = (v: number, clamped: boolean) => {
const changed = v !== lastV;
lastV = v;
if (changed) {
commitIdx++;
points.push({ v, s: commitIdx * ADVANCE });
while (points.length > MAX_TRACE) points.shift(); // pruned list
scrollFrom = scrollCur;
scrollTarget = commitIdx * ADVANCE;
scrollStart = performance.now();
}
if (reduced) {
// instant needle, static trace, no strip animation
x = xFor(v);
vx = 0;
springOn = false;
scrollCur = scrollTarget;
scrollStart = -1;
quiverStart = -1;
draw();
return;
}
if (changed) {
springOn = true;
deadline = performance.now() + SETTLE_MS;
}
if (clamped) quiverStart = performance.now();
wake();
};
const resize = () => {
const rect = drum.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) {
sized = false; // zero-size guard — never draw into nothing
return;
}
w = rect.width;
h = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
// canvas is a replaced element: inset does not size it — explicit CSS
// size plus dpr-scaled backing store, or it renders at dpr scale
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
sized = true;
x = xFor(lastV); // px space changed — snap, springs resume on next commit
vx = 0;
springOn = false;
draw();
};
resize();
engineRef.current = { commit, redraw: draw };
const ro = new ResizeObserver(resize);
ro.observe(drum);
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible) wake();
});
io.observe(root);
// live theme re-derive: watch documentElement class flips
const mo = new MutationObserver(() => {
derive();
draw();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const onVis = () => {
if (!document.hidden) wake();
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
points.length = 0;
engineRef.current = null;
};
}, []);
// controlled/external value changes ride the same drum path (deduped inside)
useEffect(() => {
engineRef.current?.commit(current, false);
}, [current]);
useEffect(() => {
setText(String(current));
}, [current]);
// ---- value plumbing -----------------------------------------------------
const commitValue = (next: number) => {
const clamped = next < min || next > max;
const v = clampRound(next);
if (v !== valueRef.current) {
if (!isControlled) setInternal(v);
onValueChange?.(v);
}
setText(String(v));
engineRef.current?.commit(v, clamped);
};
const stepBy = (mult: number) => {
if (disabledRef.current) return;
commitValue(valueRef.current + step * mult);
};
const commitFromText = () => {
const parsed = Number(text.replace(",", "."));
if (!Number.isFinite(parsed)) {
setText(String(valueRef.current)); // revert garbage
return;
}
commitValue(parsed);
};
const endHold = () => {
const hold = holdRef.current;
if (hold.timeout !== null) clearTimeout(hold.timeout);
if (hold.interval !== null) clearInterval(hold.interval);
hold.timeout = null;
hold.interval = null;
setPressDir(0);
};
const startHold = (dir: number) => (e: React.PointerEvent) => {
e.preventDefault(); // keep focus where it is; no text selection
if (disabled) return;
endHold();
setPressDir(dir);
stepBy(dir); // immediate step
holdRef.current.timeout = setTimeout(() => {
holdRef.current.interval = setInterval(
() => stepBy(dir),
HOLD_INTERVAL_MS
); // needle rides continuously at 12 steps/s
}, HOLD_DELAY_MS);
};
useEffect(() => endHold, []); // tear down hold-repeat timers on unmount
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
switch (e.key) {
case "ArrowUp":
e.preventDefault();
stepBy(e.shiftKey ? 10 : 1);
break;
case "ArrowDown":
e.preventDefault();
stepBy(e.shiftKey ? -10 : -1);
break;
case "Home":
e.preventDefault();
commitValue(min);
break;
case "End":
e.preventDefault();
commitValue(max);
break;
case "Enter":
e.preventDefault();
commitFromText();
break;
case "Escape":
setText(String(valueRef.current));
break;
}
};
const atMin = current <= min;
const atMax = current >= max;
const btnBase =
"flex h-11 w-11 shrink-0 select-none items-center justify-center rounded-sm border border-border bg-background text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface";
const btnLive =
"cursor-pointer hover:border-foreground/30 hover:bg-foreground/[0.06] active:bg-foreground/[0.1] data-[hover=true]:border-foreground/30 data-[hover=true]:bg-foreground/[0.06] data-[press=true]:bg-foreground/[0.1]";
const btnDead = "cursor-default opacity-40";
return (
<div
ref={rootRef}
className={`flex overflow-hidden rounded-md border border-border bg-surface ${
disabled ? "opacity-60" : ""
} ${className}`}
>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3 p-4">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted">
{label}
</span>
<div className="flex items-center gap-3">
<button
type="button"
tabIndex={-1}
aria-label={`Decrease ${label}`}
aria-disabled={disabled || atMin}
onPointerDown={startHold(-1)}
onPointerUp={endHold}
onPointerEnter={() => setHoverDir(-1)}
onPointerLeave={() => {
endHold();
setHoverDir((d) => (d === -1 ? 0 : d));
}}
onPointerCancel={endHold}
data-hover={hoverDir === -1}
data-press={pressDir === -1}
className={`${btnBase} ${disabled || atMin ? btnDead : btnLive}`}
>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path
d="M2.5 7h9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
fill="none"
/>
</svg>
</button>
{/* value hero: mono, ch-sized input so the unit hugs the digits */}
<div className="flex min-w-0 flex-1 items-baseline justify-center gap-1.5">
<input
type="text"
inputMode="numeric"
autoComplete="off"
spellCheck={false}
role="spinbutton"
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
disabled={disabled}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
onBlur={commitFromText}
style={{ width: `${Math.max(text.length, 1) + 1}ch` }}
className="max-w-full rounded-sm border border-transparent bg-transparent text-center font-mono text-3xl font-semibold tracking-tight text-foreground outline-none transition-colors hover:border-border focus-visible:border-accent focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-not-allowed"
/>
{unit ? (
<span className="shrink-0 font-mono text-sm text-muted">
{unit}
</span>
) : null}
</div>
<button
type="button"
tabIndex={-1}
aria-label={`Increase ${label}`}
aria-disabled={disabled || atMax}
onPointerDown={startHold(1)}
onPointerUp={endHold}
onPointerEnter={() => setHoverDir(1)}
onPointerLeave={() => {
endHold();
setHoverDir((d) => (d === 1 ? 0 : d));
}}
onPointerCancel={endHold}
data-hover={hoverDir === 1}
data-press={pressDir === 1}
className={`${btnBase} ${disabled || atMax ? btnDead : btnLive}`}
>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path
d="M7 2.5v9M2.5 7h9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
fill="none"
/>
</svg>
</button>
</div>
<span className="font-mono text-[10px] text-muted">
min {min} · max {max} · step {step}
</span>
</div>
{/* adjustment-history strip: labeled drum, newest etch on the top line */}
<div
aria-hidden
className="flex w-20 shrink-0 flex-col self-stretch border-l border-border bg-background"
>
<span className="border-b border-border py-1 text-center font-mono text-[9px] uppercase tracking-[0.18em] text-muted">
history
</span>
<div ref={drumRef} className="relative min-h-0 flex-1">
<canvas
ref={canvasRef}
className="pointer-events-none absolute left-0 top-0"
/>
</div>
</div>
</div>
);
}
Build a bounded numeric stepper whose VALUE is the visual hero, paired with a labeled adjustment-history strip. RENDER: real spinbutton (input type=text inputmode=numeric with role=spinbutton, aria-valuemin/max/now) rendered as a large mono readout (text-3xl, ch-sized width so an optional unit suffix hugs the digits, transparent until hover/focus), flanked by prominent 44px -/+ buttons with bold icons; beside it a ~80px-wide strip column with a tiny mono 'history' header over a canvas drum; canvas is a replaced element so set style.width/height EXPLICITLY plus a dpr-scaled (clamp 2) backing store. MOTION: needle x = value mapped across strip width with 10px travel insets; underdamped spring k=120 s^-2, zeta=0.45 (two visible oscillations), forced-settle deadline 900ms backstopping a velocity-epsilon sleep. Each committed adjustment advances the strip 26px downward over 600ms ease-out (event-driven scroll retarget, never continuous, so the rAF loop sleeps between commits). Pen trace: pruned array of the last 20 (value, strip-position) points drawn as a polyline plus dots each frame after a FULL clearRect, alpha fading 0.9 -> 0.2 by age index — no destination-in fading; the NEWEST etch mark is highlighted with a solid dot plus halo ring so the latest adjustment is unmistakable. An --error band at 16% alpha appears ONLY on the edge currently hit (value at min or max), so red always reads as 'at the limit'; clamping fires a hard-stop needle quiver, +-2px, 2 cycles over 180ms, decaying. Hold-repeat on -/+: 400ms delay then 12 steps/s via setTimeout+setInterval, needle riding continuously; timers cleared on pointerup/leave/cancel and unmount. KEYBOARD: ArrowUp/Down +-step, Shift+Arrow +-10 steps, Home/End to min/max, Escape reverts typing, typed entry clamps and commits on Enter/blur with a proportional swing; buttons aria-disabled at bounds, focus-visible rings accent, hover borders token-relative (foreground/30, never white/NN). INK: needle/trace from --foreground, drum feed lines and pen carriage from --border, bound band from --error — all read via getComputedStyle at mount and re-derived by a MutationObserver on documentElement class changes, then statically redrawn, so both themes render correctly. REDUCED MOTION: needle jumps instantly, trace renders statically, no strip animation, no quiver. PERF: direct-DOM rAF with no React state on the hot path, loop sleeps when spring/quiver/scroll all settle, IntersectionObserver pauses offscreen, zero-size drum guard before drawing, ResizeObserver re-derives px mapping (trace stores values, not px), every listener/observer/timer torn down on unmount.