E-commerce

Quantity Stepper

A rounded −/+ quantity control whose digits roll odometer-style, with min/max clamps and long-press repeat.

Install

npx shadcn@latest add @paragon/quantity-stepper

Also installs: tooltip

quantity-stepper.tsx

"use client";

import * as React from "react";
import { Minus, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

/** Wraps a digit's distance from the rolling target into [-5, 5). */
function cellTransform(index: number, position: number) {
  let distance = (((index - position) % 10) + 10) % 10;
  if (distance > 5) distance -= 10;
  return `translateY(${distance * 100}%)`;
}

/**
 * One odometer column for a single decimal place. Ten glyphs are stacked and
 * the column translates so the target digit sits in the window; the CSS
 * transition retargets mid-roll instead of restarting. Direction is chosen by
 * the parent so a whole-number increase rolls up and a decrease rolls down.
 */
function RollColumn({
  digit,
  direction,
  reduced,
}: {
  digit: number;
  direction: 1 | -1;
  reduced: boolean;
}) {
  // Unwrapped position accumulates so 9→0 rolls forward through the seam.
  const position = React.useRef(digit);
  const [, force] = React.useReducer((n: number) => n + 1, 0);

  const current = ((position.current % 10) + 10) % 10;
  if (current !== digit) {
    const delta =
      direction > 0
        ? (digit - current + 10) % 10
        : -((current - digit + 10) % 10);
    position.current += delta;
  }

  React.useEffect(() => {
    // Ensure the committed position renders once refs settle.
    force();
  }, [digit]);

  return (
    <span className="relative inline-block overflow-hidden" aria-hidden>
      <span className="invisible">0</span>
      {Array.from({ length: 10 }, (_, i) => (
        <span
          key={i}
          className="absolute inset-0 flex justify-center"
          style={{
            transform: cellTransform(i, position.current),
            transition: reduced
              ? undefined
              : "transform 350ms var(--ease-in-out)",
          }}
        >
          {i}
        </span>
      ))}
    </span>
  );
}

export interface QuantityStepperProps
  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) {
  return Math.min(max, Math.max(min, v));
}

/**
 * A rounded quantity control for carts and product pages. The digits roll
 * odometer-style, direction-aware; −/+ clamp to min/max and disable at the
 * bounds; holding a button repeats with a little acceleration. Reduced motion
 * swaps values in place.
 */
export function QuantityStepper({
  value: valueProp,
  defaultValue = 1,
  onValueChange,
  min = 1,
  max = 99,
  step = 1,
  disabled = false,
  name,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel = "Quantity",
  ...props
}: QuantityStepperProps) {
  const [reduced, setReduced] = React.useState(false);
  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduced(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  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 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) {
        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 = 130;
    const tick = () => {
      stepByRef.current(dir);
      interval = Math.max(45, interval * 0.9);
      holdTimer.current = setTimeout(tick, interval);
    };
    holdTimer.current = setTimeout(tick, 450);
  };

  React.useEffect(() => () => endHold(), [endHold]);

  const atMin = value <= min;
  const atMax = value >= max;
  const digits = String(value).split("");

  const buttonClass = cn(
    "flex size-9 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.94]",
  );

  const holdHandlers = (dir: 1 | -1) => ({
    onPointerDown: (e: React.PointerEvent) => {
      if (disabled) return;
      e.preventDefault();
      e.currentTarget.setPointerCapture?.(e.pointerId);
      startHold(dir);
    },
    onPointerUp: endHold,
    onPointerCancel: endHold,
    onLostPointerCapture: endHold,
  });

  return (
    <TooltipProvider>
      <div
        className={cn(
          "inline-flex h-9 items-stretch overflow-hidden rounded-full bg-secondary transition-[box-shadow] duration-150 focus-within:ring-2 focus-within:ring-ring/40",
          disabled && "opacity-50",
          className,
        )}
        {...props}
      >
        <Tooltip>
          <TooltipTrigger asChild>
            <button
              type="button"
              tabIndex={-1}
              aria-label="Decrease quantity"
              disabled={disabled || atMin}
              {...holdHandlers(-1)}
              className={cn(buttonClass, "rounded-l-full")}
            >
              <Minus className="size-4" aria-hidden />
            </button>
          </TooltipTrigger>
          <TooltipContent>Decrease</TooltipContent>
        </Tooltip>
        <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") {
              e.preventDefault();
              setValue(min);
            } else if (e.key === "End") {
              e.preventDefault();
              setValue(max);
            }
          }}
          className="flex min-w-9 cursor-default items-center justify-center px-1 text-sm leading-none font-medium tabular-nums select-none outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
        >
          <span className="sr-only">{value}</span>
          <span className="flex leading-none">
            {digits.map((d, i) => (
              <RollColumn
                // Re-key on digit count so columns reconcile positionally.
                key={`${digits.length}-${i}`}
                digit={Number(d)}
                direction={directionRef.current}
                reduced={reduced}
              />
            ))}
          </span>
        </div>
        <Tooltip>
          <TooltipTrigger asChild>
            <button
              type="button"
              tabIndex={-1}
              aria-label="Increase quantity"
              disabled={disabled || atMax}
              {...holdHandlers(1)}
              className={cn(buttonClass, "rounded-r-full")}
            >
              <Plus className="size-4" aria-hidden />
            </button>
          </TooltipTrigger>
          <TooltipContent>Increase</TooltipContent>
        </Tooltip>
        {name && <input type="hidden" name={name} value={value} />}
      </div>
    </TooltipProvider>
  );
}