"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
export interface NumberStepperProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Visible label and accessible name of the spinbutton. */
label?: string;
/** Keep the label for assistive technology only. */
hideLabel?: boolean;
/** Controlled value. */
value?: number;
/** Initial value when uncontrolled. */
defaultValue?: number;
/** Called whenever the committed value changes. */
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/** Step for Page Up / Page Down. Defaults to 10 × step. */
largeStep?: number;
/** Fraction digits. Defaults to the number of decimals in `step`. */
precision?: number;
/** Text before the number, e.g. "$". */
prefix?: string;
/** Text after the number, e.g. " kg". */
suffix?: string;
/** Full formatter (overrides prefix / suffix / locale). Digits roll, other characters stay put. */
formatValue?: (value: number) => string;
/** Locale for digit grouping. */
locale?: string;
/** Focus ring and active colour. */
accent?: string;
size?: "sm" | "md" | "lg";
disabled?: boolean;
/** Submit the value with a form through a hidden input. */
name?: string;
/** id of the input (generated when omitted). */
inputId?: string;
}
const SIZES = {
sm: { row: "h-9", text: "text-sm", button: "w-9", icon: "size-3" },
md: { row: "h-11", text: "text-base", button: "w-11", icon: "size-3.5" },
lg: { row: "h-14", text: "text-xl", button: "w-14", icon: "size-4" },
} as const;
const HOLD_DELAY = 400; // ms before auto-repeat starts
const FIRST_INTERVAL = 140; // ms between the first repeats
const MIN_INTERVAL = 28; // fastest repeat
const ACCELERATION = 0.86; // interval multiplier per repeat
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const decimalsOf = (n: number) => {
const s = String(n);
if (s.includes("e-")) return Number(s.split("e-")[1]);
return (s.split(".")[1] ?? "").length;
};
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const digitVariants = {
enter: (dir: number) => ({ y: dir > 0 ? "70%" : "-70%", opacity: 0 }),
center: { y: "0%", opacity: 1 },
exit: (dir: number) => ({ y: dir > 0 ? "-70%" : "70%", opacity: 0 }),
};
function DigitSlot({ digit, dir }: { digit: string; dir: number }) {
return (
<span className="relative inline-flex h-[1.25em] overflow-hidden leading-[1.25em]">
<AnimatePresence initial={false} custom={dir} mode="popLayout">
<motion.span
key={digit}
custom={dir}
variants={digitVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 520, damping: 38 }}
className="inline-block"
>
{digit}
</motion.span>
</AnimatePresence>
</span>
);
}
function StepGlyph({ plus, className }: { plus: boolean; className: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
<path d={plus ? "M8 3v10M3 8h10" : "M3 8h10"} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
/**
* A numeric input flanked by − / + buttons. Holding a button repeats with acceleration, digits roll
* in the direction of change, and pushing past min / max gives an elastic bump. The text field is a
* role="spinbutton" with the full keyboard model, and typed values are validated on blur.
*/
export function NumberStepper({
label = "Quantity",
hideLabel = false,
value: valueProp,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
largeStep,
precision,
prefix = "",
suffix = "",
formatValue,
locale = "en-US",
accent = "#FF7448",
size = "md",
disabled = false,
name,
inputId,
className = "",
style,
...props
}: NumberStepperProps) {
const reduceMotion = usePrefersReducedMotion();
const autoId = React.useId();
const id = inputId ?? `${autoId}-input`;
const statusId = `${autoId}-status`;
const lo = Math.min(min, max);
const hi = Math.max(min, max);
const safeStep = step > 0 ? step : 1;
const digits = Math.max(0, Math.min(10, precision ?? decimalsOf(safeStep)));
const pageStep = largeStep ?? safeStep * 10;
const normalise = React.useCallback(
(n: number) => Number(clamp(n, lo, hi).toFixed(digits)),
[lo, hi, digits],
);
const controlled = valueProp !== undefined;
const [inner, setInner] = React.useState(() => normalise(defaultValue));
const current = normalise(controlled ? valueProp : inner);
const numberFormat = React.useMemo(() => {
const options = { minimumFractionDigits: digits, maximumFractionDigits: digits };
try {
return new Intl.NumberFormat(locale, options);
} catch {
return new Intl.NumberFormat("en-US", options);
}
}, [locale, digits]);
const format = React.useCallback(
(n: number) => (formatValue ? formatValue(n) : `${prefix}${numberFormat.format(n)}${suffix}`),
[formatValue, prefix, suffix, numberFormat],
);
const formatted = format(current);
// Direction of the last change, derived during render (no effect round-trip).
const [roll, setRoll] = React.useState({ value: current, dir: 1 });
if (roll.value !== current) setRoll({ value: current, dir: current > roll.value ? 1 : -1 });
/** Text being typed; null while the rolling display is shown. */
const [draft, setDraft] = React.useState<string | null>(null);
const [invalid, setInvalid] = React.useState(false);
const [status, setStatus] = React.useState("");
const latest = React.useRef(current);
React.useEffect(() => {
latest.current = current;
}, [current]);
const bump = useMotionValue(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const repeatTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const selectOnMouseUp = React.useRef(false);
React.useEffect(() => () => clearTimeout(repeatTimer.current), []);
const kick = (dir: number) => {
if (reduceMotion) return;
bump.stop();
animate(bump, 0, { type: "spring", velocity: dir * 260, stiffness: 700, damping: 15 });
};
const commit = (next: number) => {
const n = normalise(next);
if (n === latest.current) return;
latest.current = n;
if (!controlled) setInner(n);
onValueChange?.(n);
};
/** Parses the draft (if any) and returns the value to step from. */
const settleDraft = () => {
if (draft === null) return latest.current;
setDraft(null);
const parsed = parse(draft);
if (parsed === null) return latest.current;
commit(parsed);
return latest.current;
};
/** Moves by `delta`; returns false (and bumps) when already at the limit. */
const stepBy = (delta: number) => {
const base = settleDraft();
const target = normalise(base + delta);
if (target === base) {
kick(delta > 0 ? 1 : -1);
return false;
}
setInvalid(false);
commit(target);
return true;
};
const jumpTo = (target: number) => {
const base = settleDraft();
if (normalise(target) === base) kick(target > base ? 1 : -1);
else commit(target);
};
const stopRepeat = () => clearTimeout(repeatTimer.current);
const startRepeat = (dir: 1 | -1) => {
stopRepeat();
if (!stepBy(dir * safeStep)) return;
let interval = FIRST_INTERVAL;
const tick = () => {
if (!stepBy(dir * safeStep)) return;
interval = Math.max(MIN_INTERVAL, interval * ACCELERATION);
repeatTimer.current = setTimeout(tick, interval);
};
repeatTimer.current = setTimeout(tick, HOLD_DELAY);
};
function parse(text: string): number | null {
const cleaned = text.replace(/−/g, "-").replace(/[^\d.-]/g, "");
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(cleaned)) return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
const finishEditing = () => {
if (draft === null) return;
const parsed = parse(draft);
setDraft(null);
if (parsed === null) {
setInvalid(true);
setStatus(`Not a number. Kept ${formatted}.`);
kick(1);
return;
}
setInvalid(false);
const n = normalise(parsed);
setStatus(n !== parsed ? `Adjusted to ${format(n)}.` : "");
commit(n);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
const handled = () => event.preventDefault();
switch (event.key) {
case "ArrowUp":
handled();
stepBy(safeStep);
break;
case "ArrowDown":
handled();
stepBy(-safeStep);
break;
case "PageUp":
handled();
stepBy(pageStep);
break;
case "PageDown":
handled();
stepBy(-pageStep);
break;
case "Home":
if (Number.isFinite(lo)) {
handled();
jumpTo(lo);
}
break;
case "End":
if (Number.isFinite(hi)) {
handled();
jumpTo(hi);
}
break;
case "Enter":
finishEditing();
break;
case "Escape":
if (draft !== null) {
handled();
setDraft(null);
}
break;
}
};
const sizes = SIZES[size] ?? SIZES.md;
const editing = draft !== null;
const width = Math.min(18, Math.max(4, format(lo).length, format(hi).length) + 1.5);
const atMin = current <= lo;
const atMax = current >= hi;
const chars = formatted.split("");
const stepButton = (dir: 1 | -1) => {
const atLimit = dir > 0 ? atMax : atMin;
return (
<button
type="button"
// Keyboard users step from the spinbutton itself (the WAI-ARIA pattern), so the buttons skip the tab order.
tabIndex={-1}
aria-label={dir > 0 ? "Increase" : "Decrease"}
aria-controls={id}
aria-disabled={atLimit || disabled || undefined}
disabled={disabled}
onPointerDown={(event) => {
if (event.button !== 0 || disabled) return;
event.preventDefault(); // keep focus where it is (no mobile keyboard pop-up)
event.currentTarget.setPointerCapture(event.pointerId);
startRepeat(dir);
}}
onPointerUp={stopRepeat}
onPointerCancel={stopRepeat}
onLostPointerCapture={stopRepeat}
onClick={(event) => {
// detail === 0: activated by assistive technology or a synthetic click, not a pointer.
if (event.detail === 0 && !disabled) stepBy(dir * safeStep);
}}
onContextMenu={(event) => event.preventDefault()}
className={`grid h-full shrink-0 touch-manipulation select-none place-items-center text-zinc-600 transition-[color,background-color,opacity] duration-150 hover:bg-zinc-900/[0.04] hover:text-zinc-950 active:bg-zinc-900/[0.08] disabled:cursor-not-allowed dark:text-zinc-400 dark:hover:bg-white/[0.05] dark:hover:text-white dark:active:bg-white/[0.09] ${sizes.button} ${atLimit ? "opacity-35" : ""}`}
>
<StepGlyph plus={dir > 0} className={sizes.icon} />
</button>
);
};
return (
<div
className={`inline-flex flex-col gap-2 ${disabled ? "opacity-50" : ""} ${className}`}
style={{ ["--ns-accent" as string]: accent, ...style }}
{...props}
>
<label
htmlFor={id}
className={hideLabel ? "sr-only" : "font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"}
>
{label}
</label>
<div
className={`inline-flex items-stretch overflow-hidden rounded-full border bg-white/70 transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--ns-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ns-accent)_22%,transparent)] dark:bg-white/[0.03] ${sizes.row} ${invalid ? "border-rose-500/70 dark:border-rose-400/70" : "border-zinc-900/15 dark:border-white/12"}`}
>
{stepButton(-1)}
<motion.div
className={`relative border-x border-zinc-900/10 font-medium tabular-nums tracking-tight text-zinc-950 dark:border-white/10 dark:text-zinc-50 ${sizes.text}`}
style={{ x: bump, width: `${width}ch` }}
>
<input
ref={inputRef}
id={id}
type="text"
inputMode={digits > 0 ? "decimal" : "numeric"}
role="spinbutton"
autoComplete="off"
spellCheck={false}
disabled={disabled}
aria-valuenow={current}
aria-valuemin={Number.isFinite(lo) ? lo : undefined}
aria-valuemax={Number.isFinite(hi) ? hi : undefined}
aria-valuetext={formatted}
aria-invalid={invalid || undefined}
aria-describedby={statusId}
value={editing ? draft : current.toFixed(digits)}
onChange={(event) => {
setDraft(event.target.value);
setInvalid(false);
}}
onKeyDown={onKeyDown}
onPointerDown={(event) => {
selectOnMouseUp.current = document.activeElement !== event.currentTarget;
}}
onMouseUp={(event) => {
// Keep the select-all from focus instead of collapsing it to the click position.
if (selectOnMouseUp.current) event.preventDefault();
selectOnMouseUp.current = false;
}}
onFocus={(event) => event.currentTarget.select()}
onBlur={finishEditing}
className={`absolute inset-0 size-full bg-transparent text-center outline-none disabled:cursor-not-allowed ${editing ? "" : "text-transparent caret-transparent selection:bg-transparent"}`}
/>
{/* The rolling display sits over the (transparent) input until the user starts typing. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 flex items-center justify-center whitespace-nowrap ${editing ? "invisible" : ""}`}
>
{reduceMotion
? formatted
: chars.map((ch, i) => {
const key = chars.length - i;
return /\d/.test(ch) ? <DigitSlot key={key} digit={ch} dir={roll.dir} /> : <span key={key} className="whitespace-pre">{ch}</span>;
})}
</span>
</motion.div>
{stepButton(1)}
</div>
{name ? <input type="hidden" name={name} value={current} /> : null}
<span id={statusId} role="status" aria-live="polite" className="sr-only">
{status}
</span>
</div>
);
}