Web InnoventixFreeCode

Quantity Stepper Input

Original · free

numeric quantity stepper with +/-

byWeb InnoventixReact + Tailwind
inpquantitystepperinputs
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/inp-quantity-stepper.json
inp-quantity-stepper.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Size = "sm" | "md" | "lg";

interface StepperProps {
  label: string;
  min?: number;
  max?: number;
  step?: number;
  initial?: number;
  size?: Size;
  unitPrice?: number;
  currency?: string;
  stock?: number;
  disabled?: boolean;
  onChange?: (value: number) => void;
}

const SIZES: Record<
  Size,
  { btn: string; input: string; icon: string; radius: string }
> = {
  sm: { btn: "h-8 w-8", input: "h-8 w-10 text-sm", icon: "h-3.5 w-3.5", radius: "rounded-lg" },
  md: { btn: "h-11 w-11", input: "h-11 w-12 text-base", icon: "h-4 w-4", radius: "rounded-xl" },
  lg: { btn: "h-14 w-14", input: "h-14 w-16 text-xl", icon: "h-5 w-5", radius: "rounded-2xl" },
};

function MinusIcon({ className }: { className: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path d="M5 12h14" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
    </svg>
  );
}

function PlusIcon({ className }: { className: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
    </svg>
  );
}

function Stepper({
  label,
  min = 1,
  max = 99,
  step = 1,
  initial,
  size = "md",
  unitPrice,
  currency = "USD",
  stock,
  disabled = false,
  onChange,
}: StepperProps) {
  const effectiveMax = typeof stock === "number" ? Math.min(max, stock) : max;
  const start = Math.min(effectiveMax, Math.max(min, initial ?? min));

  const [value, setValue] = useState(start);
  const [draft, setDraft] = useState(String(start));
  const [nonce, setNonce] = useState(0);
  const [hint, setHint] = useState<{ id: number; d: number } | null>(null);

  const reduce = useReducedMotion();
  const inputId = useId();
  const valueRef = useRef(value);
  valueRef.current = value;

  const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const repeatInt = useRef<ReturnType<typeof setInterval> | null>(null);
  const hintTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const didRepeat = useRef(false);

  const money = useMemo(
    () => new Intl.NumberFormat("en-US", { style: "currency", currency }),
    [currency],
  );

  const clamp = (n: number) => Math.min(effectiveMax, Math.max(min, n));

  const flashHint = (d: number) => {
    if (reduce || d === 0) return;
    const id = Date.now();
    setHint({ id, d });
    if (hintTimer.current) clearTimeout(hintTimer.current);
    hintTimer.current = setTimeout(() => setHint(null), 650);
  };

  const setTo = (raw: number, feedback: boolean) => {
    const prev = valueRef.current;
    const next = clamp(Number.isFinite(raw) ? raw : prev);
    if (next === prev) return prev;
    valueRef.current = next;
    setValue(next);
    setDraft(String(next));
    if (feedback) {
      flashHint(next - prev);
      setNonce((n) => n + 1);
    }
    onChange?.(next);
    return next;
  };

  const stepOnce = (dir: 1 | -1) => {
    const prev = valueRef.current;
    const next = setTo(prev + dir * step, true);
    if (next === prev) stopHold();
  };

  const stopHold = () => {
    if (holdTimer.current) {
      clearTimeout(holdTimer.current);
      holdTimer.current = null;
    }
    if (repeatInt.current) {
      clearInterval(repeatInt.current);
      repeatInt.current = null;
    }
  };

  const startHold = (dir: 1 | -1) => {
    if (disabled) return;
    holdTimer.current = setTimeout(() => {
      didRepeat.current = true;
      stepOnce(dir);
      repeatInt.current = setInterval(() => stepOnce(dir), 110);
    }, 400);
  };

  const handleClick = (dir: 1 | -1) => {
    if (disabled) return;
    if (didRepeat.current) {
      didRepeat.current = false;
      return;
    }
    stepOnce(dir);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (disabled) return;
    if (e.key === "ArrowUp") {
      e.preventDefault();
      stepOnce(1);
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      stepOnce(-1);
    } else if (e.key === "Home") {
      e.preventDefault();
      setTo(min, true);
    } else if (e.key === "End") {
      e.preventDefault();
      setTo(effectiveMax, true);
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const raw = e.target.value.replace(/[^\d]/g, "");
    setDraft(raw);
    if (raw !== "") setTo(Number(raw), false);
  };

  const handleBlur = () => {
    setTo(draft === "" ? min : Number(draft), false);
    setDraft(String(valueRef.current));
  };

  useEffect(
    () => () => {
      if (holdTimer.current) clearTimeout(holdTimer.current);
      if (repeatInt.current) clearInterval(repeatInt.current);
      if (hintTimer.current) clearTimeout(hintTimer.current);
    },
    [],
  );

  const s = SIZES[size];
  const atMin = disabled || value <= min;
  const atMax = disabled || value >= effectiveMax;

  const btnBase =
    "relative inline-flex select-none items-center justify-center text-zinc-600 transition-[background-color,transform,color] duration-150 hover:bg-zinc-100 hover:text-zinc-900 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:text-zinc-300 disabled:hover:bg-transparent dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white dark:disabled:text-zinc-600";

  const low = typeof stock === "number" && stock > 0 && stock <= 8;
  const out = typeof stock === "number" && stock <= 0;

  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-baseline justify-between gap-4">
        <label
          htmlFor={inputId}
          className="text-sm font-semibold text-zinc-800 dark:text-zinc-100"
        >
          {label}
        </label>
        {typeof stock === "number" && (
          <span
            className={
              "inline-flex items-center gap-1.5 text-xs font-medium " +
              (out
                ? "text-rose-600 dark:text-rose-400"
                : low
                  ? "text-amber-600 dark:text-amber-400"
                  : "text-emerald-600 dark:text-emerald-400")
            }
          >
            <span
              className={
                "qs-pulse h-1.5 w-1.5 rounded-full " +
                (out ? "bg-rose-500" : low ? "bg-amber-500" : "bg-emerald-500")
              }
            />
            {out ? "Out of stock" : low ? `Only ${stock} left` : `${stock} in stock`}
          </span>
        )}
      </div>

      <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
        <div
          role="group"
          aria-label={label}
          className={
            "relative inline-flex items-stretch border bg-white shadow-sm dark:bg-zinc-900 " +
            s.radius +
            (disabled
              ? " border-zinc-200 opacity-60 dark:border-zinc-800"
              : " border-zinc-200 dark:border-zinc-700")
          }
        >
          <span
            key={nonce}
            aria-hidden="true"
            className={"qs-pop pointer-events-none absolute inset-0 " + s.radius}
          />

          <button
            type="button"
            aria-label={`Decrease ${label.toLowerCase()}`}
            aria-controls={inputId}
            disabled={atMin}
            onClick={() => handleClick(-1)}
            onPointerDown={() => startHold(-1)}
            onPointerUp={stopHold}
            onPointerLeave={stopHold}
            onPointerCancel={stopHold}
            className={btnBase + " rounded-l-[inherit] " + s.btn}
          >
            <MinusIcon className={s.icon} />
          </button>

          <div className="relative flex items-center border-x border-zinc-200 dark:border-zinc-700">
            <input
              id={inputId}
              type="text"
              inputMode="numeric"
              pattern="[0-9]*"
              role="spinbutton"
              aria-valuemin={min}
              aria-valuemax={effectiveMax}
              aria-valuenow={value}
              aria-label={`${label} quantity`}
              disabled={disabled}
              value={draft}
              onChange={handleChange}
              onKeyDown={handleKeyDown}
              onBlur={handleBlur}
              className={
                "bg-transparent text-center font-semibold tabular-nums text-zinc-900 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:cursor-not-allowed dark:text-white " +
                s.input
              }
            />
            <AnimatePresence>
              {hint && (
                <motion.span
                  key={hint.id}
                  aria-hidden="true"
                  initial={{ opacity: 0, y: 2, scale: 0.7 }}
                  animate={{ opacity: 1, y: -16, scale: 1 }}
                  exit={{ opacity: 0, y: -24 }}
                  transition={{ duration: 0.42, ease: "easeOut" }}
                  className={
                    "pointer-events-none absolute left-1/2 top-0 -translate-x-1/2 text-xs font-bold " +
                    (hint.d > 0
                      ? "text-emerald-600 dark:text-emerald-400"
                      : "text-rose-600 dark:text-rose-400")
                  }
                >
                  {hint.d > 0 ? `+${hint.d}` : hint.d}
                </motion.span>
              )}
            </AnimatePresence>
          </div>

          <button
            type="button"
            aria-label={`Increase ${label.toLowerCase()}`}
            aria-controls={inputId}
            disabled={atMax}
            onClick={() => handleClick(1)}
            onPointerDown={() => startHold(1)}
            onPointerUp={stopHold}
            onPointerLeave={stopHold}
            onPointerCancel={stopHold}
            className={btnBase + " rounded-r-[inherit] " + s.btn}
          >
            <PlusIcon className={s.icon} />
          </button>
        </div>

        {typeof unitPrice === "number" && (
          <div className="flex flex-col leading-tight">
            <span className="text-base font-bold tabular-nums text-zinc-900 dark:text-white">
              {money.format(unitPrice * value)}
            </span>
            <span className="text-xs text-zinc-500 dark:text-zinc-400">
              {money.format(unitPrice)} each
            </span>
          </div>
        )}
      </div>

      <span className="sr-only" aria-live="polite">
        {value} {label} selected
      </span>
    </div>
  );
}

export default function QuantityStepper() {
  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-20 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 sm:px-10">
      <style>{`
        @keyframes qs-pop {
          0%   { box-shadow: 0 0 0 0 rgba(99,102,241,0.45); }
          100% { box-shadow: 0 0 0 7px rgba(99,102,241,0); }
        }
        .qs-pop { animation: qs-pop 0.5s ease-out; }
        @keyframes qs-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50%      { opacity: 0.35; transform: scale(0.8); }
        }
        .qs-pulse { animation: qs-pulse 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .qs-pop, .qs-pulse { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
      />

      <div className="relative mx-auto max-w-4xl">
        <header className="mb-12 text-center">
          <span className="inline-flex items-center rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-indigo-400">
            Inputs
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
            Quantity stepper
          </h2>
          <p className="mx-auto mt-3 max-w-lg text-sm text-zinc-500 dark:text-zinc-400">
            Type a value, tap the controls, or press and hold to fast-forward.
            Arrow keys, Home and End all work.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
          <div className="rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900">
            <p className="mb-1 text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
              Product cart line
            </p>
            <p className="mb-5 text-lg font-semibold">Cedarwood Pour-Over Kit</p>
            <Stepper
              label="Quantity"
              min={1}
              max={12}
              initial={2}
              size="md"
              unitPrice={38}
            />
          </div>

          <div className="rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900">
            <p className="mb-1 text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
              Limited inventory
            </p>
            <p className="mb-5 text-lg font-semibold">Barrel-Aged Maple Syrup</p>
            <Stepper
              label="Bottles"
              min={0}
              stock={6}
              initial={1}
              size="md"
              unitPrice={24}
            />
          </div>

          <div className="rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900">
            <p className="mb-4 text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
              Compact booking row
            </p>
            <div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-8">
              <Stepper label="Guests" min={1} max={8} initial={2} size="sm" />
              <Stepper label="Rooms" min={1} max={4} initial={1} size="sm" />
            </div>
          </div>

          <div className="rounded-2xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900">
            <p className="mb-1 text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
              Large / unavailable
            </p>
            <p className="mb-5 text-lg font-semibold">Handmade Ceramic Mug</p>
            <div className="flex flex-col gap-6">
              <Stepper label="Set size" min={2} max={24} step={2} initial={6} size="lg" />
              <Stepper label="Gift box" min={0} stock={0} disabled size="sm" />
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →