Inputs & Forms

Stepper Input

A number input with -/+ buttons where changed digits roll vertically direction-aware and long-press repeats with acceleration.

Install

npx shadcn@latest add @paragon/stepper-input

stepper-input.tsx

"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>
  );
}