Skip to content
goodtabs UI
BrowseBrowse components

Number stepper

A number field with minus and plus buttons inside one border, clamped to min and max.

npx shadcn@latest add https://ui.goodtabs.co/r/number-stepper.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/number-stepper

A quantity from 1 to 10. The buttons disable at each end.

With a step of 0.5

1.5 hours logged

Controlled, in half hours. Arrow keys step by the same amount.

Small, in a table row

ItemPriceQty
Inter variable licence$0
Icon pack, 24px set$29
Figma UI kit$49

The sm size as a quantity column, one per row.

Usage

import { NumberStepper } from "@/components/ui/number-stepper"

<NumberStepper label="Quantity" defaultValue={1} min={1} max={10} onValueChange={setQty} />

Props

PropTypeDefaultDescription
valuenumberControlled value. Leave out for an uncontrolled field.
defaultValuenumber0Starting value for the uncontrolled form.
onValueChange(value: number) => voidFires with the new number after a step, a typed value commits or a clamp.
minnumberLowest value. The minus button disables here.
maxnumberHighest value. The plus button disables here.
stepnumber1Amount each step adds or removes.
labelstringAccessible name for the field, for example "Quantity".
size"sm" | "default""default"Control height, 28px or 32px.

Source

number-stepper/number-stepper.tsx
"use client";

import * as React from "react";
import { MinusIcon, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

type NumberStepperProps = Omit<
  React.ComponentProps<typeof Input>,
  "type" | "value" | "defaultValue" | "onChange" | "size" | "min" | "max" | "step"
> & {
  /** Controlled value. Leave out for an uncontrolled field. */
  value?: number;
  /** Starting value for the uncontrolled form. */
  defaultValue?: number;
  /** Fires with the new number after a step, a typed value commits or a clamp. */
  onValueChange?: (value: number) => void;
  /** Lowest value. The minus button disables here. */
  min?: number;
  /** Highest value. The plus button disables here. */
  max?: number;
  /** Amount each step adds or removes. */
  step?: number;
  /** Accessible name for the field, for example "Quantity". */
  label?: string;
  /** Control height. sm is 28px, default is 32px. */
  size?: "sm" | "default";
};

/**
 * A number field with a minus and a plus button inside one border.
 * Arrow keys step, typed values clamp to min and max on blur or Enter.
 */
export function NumberStepper({
  value,
  defaultValue = 0,
  onValueChange,
  min = -Infinity,
  max = Infinity,
  step = 1,
  label,
  size = "default",
  disabled,
  className,
  onKeyDown,
  onBlur,
  ...props
}: NumberStepperProps) {
  const [inner, setInner] = React.useState(defaultValue);
  const controlled = value !== undefined;
  const current = controlled ? value : inner;
  const [text, setText] = React.useState(String(current));
  const [editing, setEditing] = React.useState(false);
  const decimals = (String(step).split(".")[1] ?? "").length;

  const clamp = (n: number) => Number(Math.min(max, Math.max(min, n)).toFixed(decimals));

  const commit = (n: number) => {
    const next = clamp(n);
    if (!controlled) setInner(next);
    if (next !== current) onValueChange?.(next);
    setText(String(next));
  };

  const nudge = (direction: 1 | -1) => commit(current + direction * step);

  const shown = editing ? text : String(current);
  const small = size === "sm";

  return (
    <div
      data-slot="number-stepper"
      data-size={size}
      className={cn(
        "inline-flex items-stretch overflow-hidden rounded-lg border border-input bg-transparent shadow-xs transition-colors has-[input:disabled]:opacity-50 has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-3 has-[input:focus-visible]:ring-ring/50 dark:bg-input/30",
        small ? "h-7" : "h-8",
        className,
      )}
    >
      <Button
        type="button"
        variant="ghost"
        size={small ? "icon-sm" : "icon"}
        aria-label="Decrease"
        tabIndex={-1}
        disabled={disabled || current <= min}
        onClick={() => nudge(-1)}
        className="h-full rounded-none border-0"
      >
        <MinusIcon aria-hidden="true" />
      </Button>
      <Input
        type="number"
        inputMode="decimal"
        value={shown}
        min={Number.isFinite(min) ? min : undefined}
        max={Number.isFinite(max) ? max : undefined}
        step={step}
        aria-label={label}
        disabled={disabled}
        onFocus={() => {
          setText(String(current));
          setEditing(true);
        }}
        onChange={(e) => setText(e.target.value)}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.defaultPrevented) return;
          if (e.key === "ArrowUp" || e.key === "ArrowDown") {
            e.preventDefault();
            nudge(e.key === "ArrowUp" ? 1 : -1);
          } else if (e.key === "Enter") {
            e.currentTarget.blur();
          }
        }}
        onBlur={(e) => {
          onBlur?.(e);
          setEditing(false);
          const parsed = Number(text);
          commit(text.trim() === "" || Number.isNaN(parsed) ? current : parsed);
        }}
        className={cn(
          "h-full w-12 [appearance:textfield] rounded-none border-0 bg-transparent px-1 text-center font-mono tabular-nums shadow-none focus-visible:border-transparent focus-visible:ring-0 disabled:bg-transparent disabled:opacity-100 dark:bg-transparent dark:disabled:bg-transparent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
          small ? "text-xs md:text-xs" : "text-sm md:text-sm",
        )}
        {...props}
      />
      <Button
        type="button"
        variant="ghost"
        size={small ? "icon-sm" : "icon"}
        aria-label="Increase"
        tabIndex={-1}
        disabled={disabled || current >= max}
        onClick={() => nudge(1)}
        className="h-full rounded-none border-0"
      >
        <PlusIcon aria-hidden="true" />
      </Button>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/number-stepper/prompt.md, or the Copy AI prompt button above.

When to use

  • Quantities, counts, seats, hours: small numbers people nudge more than they type
  • Carts, order lines and table cells, in the sm size
  • Settings that have a clear floor and ceiling

When not to use

  • Large or precise numbers such as prices or ids, where typing is the whole job and a plain Input is better
  • Picking from a range by feel: use a slider
  • Values with units that change, like durations across hours and minutes

Agent notes

  • It takes a function (onValueChange), so a demo or page that passes one must be a client component
  • Always pass label; it becomes the aria-label on the input, there is no visible label of its own
  • Typed values commit on blur or Enter and clamp to min and max; ArrowUp and ArrowDown step straight away
  • Decimals follow step, so step={0.5} keeps one decimal and avoids float drift; the buttons are skipped by Tab since the arrows do the same job