Feedback & Data
NeedleGauge
A panel meter from a test bench. Bakelite case, cream enamel dial and a mirror band that throws a faint second needle. The needle overshoots, settles on its spring, shivers a little, and jumps if you tap the glass.
Download
- React
- Tailwind CSS v4
- motion
components/ui/needle-gauge.tsx
"use client";
import { useEffect, useId, useRef } from "react";
import { animate, motion, useAnimationFrame, useAnimationControls, useInView, useMotionValue, useReducedMotion, useSpring } from "motion/react";
type NeedleGaugeProps = {
/** The reading. */
value?: number;
/** Bottom of the scale. */
min?: number;
/** Top of the scale. */
max?: number;
/** Big numbers on the scale, including both ends. */
ticks?: number;
/** From this value up the scale is red and the lamp lights. */
redline?: number;
/** Printed on the dial. */
label?: string;
/** Printed after the reading. */
unit?: string;
/** Needle shiver, in degrees. 0 holds it dead still. */
shiver?: number;
/** Set the width here. Everything scales with it. */
className?: string;
};
const PIVOT = { x: 210, y: 232 };
const SWEEP = 50;
const R = 158;
const STEPS = 5; // small marks between two numbers
const polar = (deg: number, r: number) => {
const a = ((deg - 90) * Math.PI) / 180;
return [PIVOT.x + Math.cos(a) * r, PIVOT.y + Math.sin(a) * r];
};
const arc = (from: number, to: number, r: number) => {
const [x1, y1] = polar(from, r);
const [x2, y2] = polar(to, r);
return `M${x1.toFixed(1)} ${y1.toFixed(1)}A${r} ${r} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}`;
};
/* NeedleGauge: a panel meter from a test bench. Bakelite case, cream enamel dial, and a mirror band under the scale that throws a faint second needle. The needle overshoots and settles on its spring, shivers a little, and jumps if you tap the glass. */
export function NeedleGauge({ value = 64, min = 0, max = 100, ticks = 6, redline = 80, label = "Server load", unit = "%", shiver = 0.22, className = "w-full max-w-md" }: NeedleGaugeProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const box = useRef<HTMLDivElement>(null);
const inView = useInView(box, { amount: 0.1 });
const needle = useRef<SVGGElement>(null);
const ghost = useRef<SVGGElement>(null);
const shell = useAnimationControls();
const place = (v: number) => (Math.min(max, Math.max(min, v)) - min) / (max - min);
const angle = (v: number) => -SWEEP + place(v) * SWEEP * 2;
const raw = useMotionValue(angle(value));
const spring = useSpring(raw, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 6, mass: 1 });
const tap = useMotionValue(0);
useEffect(() => raw.set(angle(value)));
useAnimationFrame((time) => {
if (!inView) return;
const t = time / 1000;
const noise = reduce ? 0 : (Math.sin(t * 13) + Math.sin(t * 29.3) * 0.6 + Math.sin(t * 4.7)) * shiver * 0.5;
const a = Math.max(-SWEEP - 3, Math.min(SWEEP + 3, spring.get() + tap.get() + noise));
needle.current?.setAttribute("transform", `rotate(${a.toFixed(2)} ${PIVOT.x} ${PIVOT.y})`);
ghost.current?.setAttribute("transform", `translate(1.6 0.8) rotate(${a.toFixed(2)} ${PIVOT.x} ${PIVOT.y})`);
});
const knock = () => {
if (reduce) return;
animate(tap, [0, 5, -3.4, 1.8, -0.8, 0], { duration: 0.7, ease: "easeOut" });
shell.start({ y: [0, 2, 0], transition: { duration: 0.14 } });
};
const big = Array.from({ length: ticks }, (_, i) => i / (ticks - 1));
const small = Array.from({ length: (ticks - 1) * STEPS + 1 }, (_, i) => i / ((ticks - 1) * STEPS));
const redFrom = -SWEEP + place(redline) * SWEEP * 2;
const over = value >= redline;
const mono = "ui-monospace, Consolas, monospace";
return (
<motion.div ref={box} animate={shell} onPointerDown={knock} className={`cursor-pointer select-none ${className}`}>
<svg viewBox="0 0 420 300" className="block h-auto w-full" role="meter" aria-label={label} aria-valuemin={min} aria-valuemax={max} aria-valuenow={value} aria-valuetext={`${value}${unit}${over ? ", over the red line" : ""}`}>
<defs>
<linearGradient id={`${id}case`} x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor="#4b2f22" />
<stop offset="0.5" stopColor="#2c1a13" />
<stop offset="1" stopColor="#1a0f0b" />
</linearGradient>
<radialGradient id={`${id}dial`} cx="0.5" cy="0.25" r="0.9">
<stop offset="0" stopColor="#f6efdd" />
<stop offset="1" stopColor="#dccfae" />
</radialGradient>
<linearGradient id={`${id}mirror`} x1="0" x2="1">
<stop offset="0" stopColor="#8f9aa0" />
<stop offset="0.35" stopColor="#e8eff2" />
<stop offset="0.6" stopColor="#aeb9bf" />
<stop offset="1" stopColor="#dfe7ea" />
</linearGradient>
<linearGradient id={`${id}glare`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0.4" />
<stop offset="0.5" stopColor="#fff" stopOpacity="0.03" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</linearGradient>
<clipPath id={`${id}win`}>
<rect x="24" y="24" width="372" height="252" rx="10" />
</clipPath>
</defs>
<rect x="4" y="4" width="412" height="292" rx="20" fill={`url(#${id}case)`} />
<rect x="5" y="5" width="410" height="290" rx="19" fill="none" stroke="#fff" strokeOpacity="0.1" strokeWidth="2" />
{[[14, 14, 20], [406, 14, 110], [14, 286, 65], [406, 286, 150]].map(([x, y, r]) => (
<g key={`${x}${y}`} transform={`rotate(${r} ${x} ${y})`}>
<circle cx={x} cy={y} r="5.5" fill="#b89a5a" stroke="#000" strokeWidth="0.8" />
<line x1={x - 4.5} y1={y} x2={x + 4.5} y2={y} stroke="#3a2a10" strokeWidth="1.6" />
</g>
))}
<g clipPath={`url(#${id}win)`}>
<rect x="24" y="24" width="372" height="252" fill={`url(#${id}dial)`} />
{/* Mirror band. */}
<path d={arc(-SWEEP - 2, SWEEP + 2, R - 18)} fill="none" stroke={`url(#${id}mirror)`} strokeWidth="11" />
<g ref={ghost} opacity="0.32">
<line x1={PIVOT.x} y1={PIVOT.y} x2={PIVOT.x} y2={PIVOT.y - R + 6} stroke="#1a1a1a" strokeWidth="1.8" />
</g>
{/* Scale. */}
<path d={arc(-SWEEP, SWEEP, R)} fill="none" stroke="#1d1b18" strokeWidth="1.6" />
<path d={arc(redFrom, SWEEP, R + 3)} fill="none" stroke="#b3261e" strokeWidth="6.5" />
{small.map((f) => {
const a = -SWEEP + f * SWEEP * 2;
const major = big.some((b) => Math.abs(b - f) < 1e-6);
const [x1, y1] = polar(a, R);
const [x2, y2] = polar(a, R + (major ? 13 : 7));
return <line key={f} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#1d1b18" strokeWidth={major ? 2 : 1} />;
})}
{big.map((f) => {
const a = -SWEEP + f * SWEEP * 2;
const [x, y] = polar(a, R + 26);
return (
<text key={f} x={x} y={y + 5} textAnchor="middle" fontSize="15" fontFamily={mono} fontWeight="600" fill="#1d1b18">
{Math.round(min + f * (max - min))}
</text>
);
})}
<text x="210" y="130" textAnchor="middle" fontSize="14" letterSpacing="1.5" fontFamily={mono} fill="#4a4436">{label}</text>
{/* Needle. */}
<g ref={needle} transform={`rotate(${angle(value)} ${PIVOT.x} ${PIVOT.y})`}>
<path d={`M${PIVOT.x - 1.1} ${PIVOT.y}L${PIVOT.x - 0.4} ${PIVOT.y - R + 6}L${PIVOT.x + 0.4} ${PIVOT.y - R + 6}L${PIVOT.x + 1.1} ${PIVOT.y}Z`} fill="#141210" />
<path d={`M${PIVOT.x - 0.6} ${PIVOT.y - R + 44}L${PIVOT.x - 0.4} ${PIVOT.y - R + 6}L${PIVOT.x + 0.4} ${PIVOT.y - R + 6}L${PIVOT.x + 0.6} ${PIVOT.y - R + 44}Z`} fill="#b3261e" />
</g>
{/* Bakelite cover over the pivot. */}
<path d={`M78 276A132 72 0 0 1 342 276Z`} fill={`url(#${id}case)`} />
<path d={`M78 276A132 72 0 0 1 342 276`} fill="none" stroke="#fff" strokeOpacity="0.14" strokeWidth="1.5" />
<circle cx={PIVOT.x} cy={PIVOT.y + 8} r="9" fill="#b89a5a" stroke="#000" strokeWidth="0.8" />
<line x1={PIVOT.x - 7} y1={PIVOT.y + 8} x2={PIVOT.x + 7} y2={PIVOT.y + 8} stroke="#3a2a10" strokeWidth="2" transform={`rotate(32 ${PIVOT.x} ${PIVOT.y + 8})`} />
<text x="148" y="266" textAnchor="middle" fontSize="19" fontWeight="700" fontFamily={mono} fill="#efe3c6">{value}{unit}</text>
{/* Lamp. */}
<circle cx="272" cy="260" r="6.5" fill={over ? "#ff4a3a" : "#4a1a14"} stroke="#000" strokeWidth="1" />
{over && !reduce && (
<motion.circle cx="272" cy="260" r="13" fill="#ff4a3a" animate={{ opacity: [0.15, 0.5, 0.15] }} transition={{ duration: 0.9, repeat: Infinity }} />
)}
<text x="284" y="264.5" fontSize="12" fontFamily={mono} fill={over ? "#efe3c6" : "#8a7458"}>OVER</text>
<path d="M24 24H396L120 276H24Z" fill={`url(#${id}glare)`} className="pointer-events-none" />
</g>
<rect x="24" y="24" width="372" height="252" rx="10" fill="none" stroke="#000" strokeWidth="2.5" />
</svg>
</motion.div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/needle-gauge.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/needle-gauge-demo.tsx
"use client"; import { useEffect, useState } from "react"; import { NeedleGauge } from "@/components/ui/needle-gauge"; const READINGS = [64, 38, 72, 91, 55, 83, 47]; export default function NeedleGaugeExample() { const [i, setI] = useState(0); useEffect(() => { const id = setInterval(() => setI((n) => (n + 1) % READINGS.length), 2600); return () => clearInterval(id); }, []); return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#39413f] p-2"> <NeedleGauge value={READINGS[i]} className="w-[min(136cqh,94cqw,480px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| value | number | 64 | The reading. |
| min | number | 0 | Bottom of the scale. |
| max | number | 100 | Top of the scale. |
| ticks | number | 6 | Big numbers on the scale, including both ends. |
| redline | number | 80 | From this value up the scale is red and the lamp lights. |
| label | string | "Server load" | Printed on the dial. |
| unit | string | "%" | Printed after the reading. |
| shiver | number | 0.22 | Needle shiver in degrees. 0 holds it still. |
| className | string | "w-full max-w-md" | Set the width here. Everything scales with it. |