Input Spinner

A minimalist input spinner for choosing quantities with buttons, typing, or arrow keys.

Add to your project

Pick the way you like to build.

Let your AI assistant add it

Copy this prompt and paste it into the AI tool building your site.

# Integrate this UI component

Add this component to my project.

1. Install any missing dependencies.
2. Make sure Tailwind CSS is set up and scans the new file.
3. Prefer passing props over editing the component source — changing the component itself may break it.
4. Adjust colors, size, and layout via props or wrapper classes to match my project. You know my codebase better than this snippet does.

## Component source

```tsx
"use client";

import { useState } from "react";
import type { ChangeEvent, InputHTMLAttributes } from "react";
import { Minus, Plus } from "lucide-react";

type InputSpinnerProps = Omit<
  InputHTMLAttributes<HTMLInputElement>,
  "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step"
> & {
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  // eslint-disable-next-line no-unused-vars
  onValueChange?: (value: number) => void;
  className?: string;
};

export default function InputSpinner({
  value,
  defaultValue = 0,
  min,
  max,
  step = 1,
  onValueChange,
  className = "",
  disabled,
  ...props
}: InputSpinnerProps) {
  const [internalValue, setInternalValue] = useState(String(defaultValue));
  const displayValue = value === undefined ? internalValue : String(value);
  const numericValue = Number(displayValue);

  const updateValue = (nextValue: number) => {
    const clampedValue = Math.min(
      max ?? Infinity,
      Math.max(min ?? -Infinity, nextValue),
    );
    const roundedValue = Number(clampedValue.toFixed(10));

    if (value === undefined) setInternalValue(String(roundedValue));
    onValueChange?.(roundedValue);
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const nextValue = event.target.value;
    if (value === undefined) setInternalValue(nextValue);
    if (nextValue !== "") onValueChange?.(event.target.valueAsNumber);
  };

  const stepValue = (direction: -1 | 1) => {
    const startingValue = Number.isNaN(numericValue)
      ? (min ?? 0)
      : numericValue;
    updateValue(startingValue + direction * Number(step));
  };

  return (
    <div
      className={[
        "inline-flex h-12 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition focus-within:border-gray-400 focus-within:ring-4 focus-within:ring-gray-100",
        disabled && "opacity-60",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
    >
      <button
        type="button"
        aria-label="Decrease value"
        onClick={() => stepValue(-1)}
        disabled={disabled || (min !== undefined && numericValue <= min)}
        className="flex w-11 items-center justify-center border-r border-gray-100 text-gray-500 transition hover:bg-gray-50 hover:text-gray-950 focus:z-10 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-gray-500 disabled:cursor-not-allowed disabled:text-gray-300"
      >
        <Minus className="h-4 w-4" strokeWidth={3} aria-hidden="true" />
      </button>

      <input
        {...props}
        type="number"
        value={displayValue}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        onChange={handleChange}
        className="w-16 appearance-none bg-transparent px-2 text-center text-sm font-semibold tabular-nums text-gray-950 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
      />

      <button
        type="button"
        aria-label="Increase value"
        onClick={() => stepValue(1)}
        disabled={disabled || (max !== undefined && numericValue >= max)}
        className="flex w-11 items-center justify-center border-l border-gray-100 text-gray-500 transition hover:bg-gray-50 hover:text-gray-950 focus:z-10 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-gray-500 disabled:cursor-not-allowed disabled:text-gray-300"
      >
        <Plus className="h-4 w-4" strokeWidth={3} aria-hidden="true" />
      </button>
    </div>
  );
}

```

Works with your AI tools

ClaudeClaude*
CodexCodex*
LovableLovable
Base44Base44
Figma MakeFigma Make
ReplitReplit*
CursorCursor*
v0v0

* Compatible with most projects that use the default React/Next.js framework.

Built With

ReactTailwind CSSLucide

Props

PropTypeDescription
valuenumberControlled numeric value
defaultValuenumberInitial value when uncontrolled
onValueChange(value: number) => voidCalled after typing or using either control
minnumberLowest allowed value
maxnumberHighest allowed value
stepnumberAmount added or removed with each step
disabledbooleanDisables the input and both controls
classNamestringStyles for the outer control

More Input Components

A premium search input with a focused state and an icon that becomes a submit action as you type.