Bloom Logo

Number Input

A specialized numeric input featuring stepper buttons for incrementing and decrementing values, min/max bounds, step intervals, and custom sizes.

numberInput.tsx

Core implementation of the NumberInput component.

ReactTailwindFormsUI Component
"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, Minus, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button/button";
import { designRadius } from "@/lib/design-system";

export interface NumberInputProps {
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  onValueChange?: (val: number) => void;
  label?: React.ReactNode;
  size?: "sm" | "md" | "lg";
  radius?: keyof typeof designRadius;
  disabled?: boolean;
}

const sizeMap = {
  sm: "h-8 text-xs px-2 gap-1",
  md: "h-10 text-sm px-3 gap-2",
  lg: "h-12 text-base px-4 gap-3",
};

export function NumberInput({
  value,
  defaultValue = 0,
  min,
  max,
  step = 1,
  onValueChange,
  label,
  size = "md",
  radius = "lg",
  disabled = false,
}: NumberInputProps) {
  const [internalVal, setInternalVal] = React.useState<number>(
    value !== undefined ? value : defaultValue
  );

  React.useEffect(() => {
    if (value !== undefined) {
      setInternalVal(value);
    }
  }, [value]);

  const updateValue = (newVal: number) => {
    let clamped = newVal;
    if (min !== undefined && clamped < min) clamped = min;
    if (max !== undefined && clamped > max) clamped = max;

    setInternalVal(clamped);
    onValueChange?.(clamped);
  };

  const handleIncrement = () => updateValue(internalVal + step);
  const handleDecrement = () => updateValue(internalVal - step);

  return (
    <div className="flex flex-col gap-1.5 w-full max-w-xs">
      {label && <label className="text-xs font-semibold text-foreground/90 select-none">{label}</label>}
      <div
        className={cn(
          "flex items-center justify-between border border-input bg-background shadow-xs",
          sizeMap[size],
          designRadius[radius]
        )}
      >
        <Button
          type="button"
          size="sm"
          variant="light"
          isIconOnly
          ariaLabel="Decrement value"
          disabled={disabled || (min !== undefined && internalVal <= min)}
          onClick={handleDecrement}
          aria-label="Decrement value"
        >
          <Minus className="size-3.5" />
        </Button>
        <input
          type="number"
          value={internalVal}
          disabled={disabled}
          min={min}
          max={max}
          step={step}
          onChange={(e) => updateValue(Number(e.target.value))}
          className="w-full text-center bg-transparent outline-none font-mono font-medium text-foreground [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
        />
        <Button
          type="button"
          size="sm"
          variant="light"
          isIconOnly
          ariaLabel="Increment value"
          disabled={disabled || (max !== undefined && internalVal >= max)}
          onClick={handleIncrement}
          aria-label="Increment value"
        >
          <Plus className="size-3.5" />
        </Button>
      </div>
    </div>
  );
}

Basic Usage

Standard numeric stepper.

Step Intervals

Configure custom increment steps.

step: number
API Reference

Props — NumberInput

Properties to configure the NumberInput component.

PropTypeDefaultDescription
minnumberMinimum allowed numeric bound.
maxnumberMaximum allowed numeric bound.
stepnumber1Increment/decrement step size interval.