A number input with -/+ buttons where changed digits roll vertically direction-aware and long-press repeats with acceleration.
npx shadcn@latest add @paragon/stepper-input"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Minus, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
export interface StepperInputProps
extends Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "role"
> {
/** Controlled value. */
value?: number;
/** Initial value when uncontrolled. */
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
/** Form field name; renders a hidden input. */
name?: string;
/** Disables press-scale feedback on the +/- buttons. */
static?: boolean;
}
function clamp(v: number, min?: number, max?: number) {
if (min !== undefined && v < min) return min;
if (max !== undefined && v > max) return max;
return v;
}
/** One digit cell: the old glyph rolls out while the new rolls in, direction-aware. */
function DigitCell({
char,
direction,
animate,
reduced,
}: {
char: string;
direction: 1 | -1;
animate: boolean;
reduced: boolean;
}) {
const roll = animate && !reduced;
return (
<span className="relative inline-flex justify-center overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={char}
initial={
roll
? { y: direction > 0 ? "100%" : "-100%", opacity: 0.25 }
: { opacity: 0 }
}
animate={{ y: "0%", opacity: 1 }}
exit={
roll
? {
y: direction > 0 ? "-100%" : "100%",
opacity: 0.25,
transition: { duration: 0.15, ease: EASE_EXIT },
}
: { opacity: 0, transition: { duration: 0.1 } }
}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="inline-block"
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
}
/**
* A number input with -/+ steppers. Changed digits roll vertically,
* direction-aware; holding a button repeats with acceleration.
*/
export function StepperInput({
value: valueProp,
defaultValue = 0,
onValueChange,
min,
max,
step = 1,
disabled = false,
name,
static: isStatic = false,
className,
"aria-label": ariaLabel,
...props
}: StepperInputProps) {
const reduced = useReducedMotion() ?? false;
const [uncontrolled, setUncontrolled] = React.useState(
clamp(defaultValue, min, max),
);
const value = valueProp ?? uncontrolled;
const valueRef = React.useRef(value);
valueRef.current = value;
const directionRef = React.useRef<1 | -1>(1);
const holdTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const typedRef = React.useRef("");
const typedTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const setValue = React.useCallback(
(next: number) => {
const clamped = clamp(next, min, max);
if (clamped === valueRef.current) return;
directionRef.current = clamped > valueRef.current ? 1 : -1;
if (valueProp === undefined) setUncontrolled(clamped);
onValueChange?.(clamped);
},
[min, max, valueProp, onValueChange],
);
const stepBy = React.useCallback(
(dir: 1 | -1) => {
const next = clamp(valueRef.current + dir * step, min, max);
if (next === valueRef.current) {
// At a bound the button disables and can no longer receive
// pointerup, so stop any active hold-repeat here.
if (holdTimer.current) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
return;
}
setValue(next);
},
[setValue, step, min, max],
);
const stepByRef = React.useRef(stepBy);
stepByRef.current = stepBy;
const endHold = React.useCallback(() => {
if (holdTimer.current) clearTimeout(holdTimer.current);
holdTimer.current = null;
}, []);
const startHold = (dir: 1 | -1) => {
stepByRef.current(dir);
let interval = 120;
const tick = () => {
stepByRef.current(dir);
interval = Math.max(40, interval * 0.9); // accelerate
holdTimer.current = setTimeout(tick, interval);
};
holdTimer.current = setTimeout(tick, 450);
};
React.useEffect(() => {
return () => {
endHold();
if (typedTimer.current) clearTimeout(typedTimer.current);
};
}, [endHold]);
const chars = String(value).split("");
const atMin = min !== undefined && value <= min;
const atMax = max !== undefined && value >= max;
const buttonClass = cn(
"flex w-8 shrink-0 items-center justify-center text-muted-foreground transition-[background-color,color,scale] duration-150 ease-out select-none hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40",
!isStatic && "active:not-disabled:scale-[0.97]",
);
const holdHandlers = (dir: 1 | -1) => ({
onPointerDown: (e: React.PointerEvent) => {
if (disabled) return;
e.preventDefault();
startHold(dir);
},
onPointerUp: endHold,
onPointerLeave: endHold,
onPointerCancel: endHold,
});
return (
<div
className={cn(
"inline-flex h-9 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30",
disabled && "opacity-50",
className,
)}
{...props}
>
<button
type="button"
tabIndex={-1}
aria-label="Decrease"
disabled={disabled || atMin}
{...holdHandlers(-1)}
className={cn(buttonClass, "border-r border-input")}
>
<Minus className="size-3.5" aria-hidden />
</button>
<div
role="spinbutton"
tabIndex={disabled ? -1 : 0}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "ArrowUp") {
e.preventDefault();
stepBy(1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
stepBy(-1);
} else if (e.key === "Home" && min !== undefined) {
e.preventDefault();
setValue(min);
} else if (e.key === "End" && max !== undefined) {
e.preventDefault();
setValue(max);
} else if (e.key === "Backspace") {
e.preventDefault();
typedRef.current = "";
setValue(Math.trunc(valueRef.current / 10));
} else if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
typedRef.current += e.key;
setValue(Number(typedRef.current));
if (typedTimer.current) clearTimeout(typedTimer.current);
typedTimer.current = setTimeout(() => {
typedRef.current = "";
}, 800);
}
}}
className="flex min-w-14 flex-1 cursor-default items-center justify-center px-3 text-sm font-medium tabular-nums select-none"
>
<span aria-hidden className="flex">
{chars.map((ch, i) => (
<DigitCell
// Re-key on length change so cells reconcile positionally.
key={`${chars.length}-${i}`}
char={ch}
direction={directionRef.current}
animate
reduced={reduced}
/>
))}
</span>
</div>
<button
type="button"
tabIndex={-1}
aria-label="Increase"
disabled={disabled || atMax}
{...holdHandlers(1)}
className={cn(buttonClass, "border-l border-input")}
>
<Plus className="size-3.5" aria-hidden />
</button>
{name && <input type="hidden" name={name} value={value} />}
</div>
);
}