Number Stepper
Quantity stepper whose number slides as it changes; hold a button to repeat.
Team seats
₹998 / month
2
components/ui/number-stepper.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Minus, Plus } from "lucide-react";
import { useEffect, useRef, useState } from "react";
type NumberStepperProps = {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
label: string;
};
/** Quantity stepper: the number slides up or down as it changes; press and hold a button to repeat. */
export function NumberStepper({ value, onChange, min = 0, max = 99, step = 1, label }: NumberStepperProps) {
const [dir, setDir] = useState(1);
const latest = useRef(value);
const hold = useRef<ReturnType<typeof setTimeout> | null>(null);
latest.current = value;
const bump = (d: number) => {
const next = Math.max(min, Math.min(max, latest.current + d * step));
if (next === latest.current) return;
setDir(d);
latest.current = next;
onChange(next);
};
const stop = () => {
if (hold.current) clearTimeout(hold.current);
hold.current = null;
};
// Repeat while held: starts after 400ms, then speeds up.
const start = (d: number) => {
bump(d);
let delay = 400;
const tick = () => {
bump(d);
delay = Math.max(60, delay * 0.8);
hold.current = setTimeout(tick, delay);
};
hold.current = setTimeout(tick, delay);
};
useEffect(() => stop, []);
const button = "grid size-9 place-items-center rounded-full text-zinc-700 transition hover:bg-zinc-100 active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent";
return (
<div
role="spinbutton"
tabIndex={0}
aria-label={label}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
onKeyDown={(e) => {
if (e.key === "ArrowUp" || e.key === "ArrowRight") bump(1);
else if (e.key === "ArrowDown" || e.key === "ArrowLeft") bump(-1);
else return;
e.preventDefault();
}}
className="inline-flex select-none items-center gap-1 rounded-full border border-zinc-200 bg-white p-1 shadow-sm outline-none focus-visible:ring-4 focus-visible:ring-blue-600/15"
>
<button type="button" tabIndex={-1} aria-label="Decrease" disabled={value <= min} onPointerDown={() => start(-1)} onPointerUp={stop} onPointerLeave={stop} className={button}>
<Minus className="size-4" />
</button>
<span className="relative inline-flex h-9 w-10 items-center justify-center overflow-hidden text-base font-semibold tabular-nums text-zinc-900">
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<motion.span
key={value}
custom={dir}
variants={{ enter: (d: number) => ({ y: d * 18, opacity: 0 }), center: { y: 0, opacity: 1 }, exit: (d: number) => ({ y: d * -18, opacity: 0 }) }}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 500, damping: 32 }}
>
{value}
</motion.span>
</AnimatePresence>
</span>
<button type="button" tabIndex={-1} aria-label="Increase" disabled={value >= max} onPointerDown={() => start(1)} onPointerUp={stop} onPointerLeave={stop} className={button}>
<Plus className="size-4" />
</button>
</div>
);
}
export default function Demo() {
const [qty, setQty] = useState(2);
return (
<div className="flex w-72 items-center justify-between rounded-2xl border border-zinc-200 bg-white p-4">
<div>
<p className="text-sm font-medium text-zinc-900">Team seats</p>
<p className="text-xs text-zinc-500">₹{(qty * 499).toLocaleString("en-IN")} / month</p>
</div>
<NumberStepper label="Team seats" value={qty} onChange={setQty} min={1} max={20} />
</div>
);
}
Edit with AIPro
Describe a change to Number Stepper and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/number-stepper.tsxcomponents/ui/number-stepper.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Minus, Plus } from "lucide-react"; import { useEffect, useRef, useState } from "react"; type NumberStepperProps = { value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; label: string; }; /** Quantity stepper: the number slides up or down as it changes; press and hold a button to repeat. */ export function NumberStepper({ value, onChange, min = 0, max = 99, step = 1, label }: NumberStepperProps) { const [dir, setDir] = useState(1); const latest = useRef(value); const hold = useRef<ReturnType<typeof setTimeout> | null>(null); latest.current = value; const bump = (d: number) => { const next = Math.max(min, Math.min(max, latest.current + d * step)); if (next === latest.current) return; setDir(d); latest.current = next; onChange(next); }; const stop = () => { if (hold.current) clearTimeout(hold.current); hold.current = null; }; // Repeat while held: starts after 400ms, then speeds up. const start = (d: number) => { bump(d); let delay = 400; const tick = () => { bump(d); delay = Math.max(60, delay * 0.8); hold.current = setTimeout(tick, delay); }; hold.current = setTimeout(tick, delay); }; useEffect(() => stop, []); const button = "grid size-9 place-items-center rounded-full text-zinc-700 transition hover:bg-zinc-100 active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent"; return ( <div role="spinbutton" tabIndex={0} aria-label={label} aria-valuenow={value} aria-valuemin={min} aria-valuemax={max} onKeyDown={(e) => { if (e.key === "ArrowUp" || e.key === "ArrowRight") bump(1); else if (e.key === "ArrowDown" || e.key === "ArrowLeft") bump(-1); else return; e.preventDefault(); }} className="inline-flex select-none items-center gap-1 rounded-full border border-zinc-200 bg-white p-1 shadow-sm outline-none focus-visible:ring-4 focus-visible:ring-blue-600/15" > <button type="button" tabIndex={-1} aria-label="Decrease" disabled={value <= min} onPointerDown={() => start(-1)} onPointerUp={stop} onPointerLeave={stop} className={button}> <Minus className="size-4" /> </button> <span className="relative inline-flex h-9 w-10 items-center justify-center overflow-hidden text-base font-semibold tabular-nums text-zinc-900"> <AnimatePresence mode="popLayout" initial={false} custom={dir}> <motion.span key={value} custom={dir} variants={{ enter: (d: number) => ({ y: d * 18, opacity: 0 }), center: { y: 0, opacity: 1 }, exit: (d: number) => ({ y: d * -18, opacity: 0 }) }} initial="enter" animate="center" exit="exit" transition={{ type: "spring", stiffness: 500, damping: 32 }} > {value} </motion.span> </AnimatePresence> </span> <button type="button" tabIndex={-1} aria-label="Increase" disabled={value >= max} onPointerDown={() => start(1)} onPointerUp={stop} onPointerLeave={stop} className={button}> <Plus className="size-4" /> </button> </div> ); } export default function Demo() { const [qty, setQty] = useState(2); return ( <div className="flex w-72 items-center justify-between rounded-2xl border border-zinc-200 bg-white p-4"> <div> <p className="text-sm font-medium text-zinc-900">Team seats</p> <p className="text-xs text-zinc-500">₹{(qty * 499).toLocaleString("en-IN")} / month</p> </div> <NumberStepper label="Team seats" value={qty} onChange={setQty} min={1} max={20} /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.