Number Stepper

Quantity stepper whose number slides as it changes; hold a button to repeat.

Team seats

₹998 / month

2

Edit with AIPro

Describe a change to Number Stepper and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/number-stepper.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.