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
| Item | Price | Qty |
|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | Controlled value. Leave out for an uncontrolled field. | |
| defaultValue | number | 0 | Starting value for the uncontrolled form. |
| onValueChange | (value: number) => void | Fires with the new number after a step, a typed value commits or a clamp. | |
| min | number | Lowest value. The minus button disables here. | |
| max | number | Highest value. The plus button disables here. | |
| step | number | 1 | Amount each step adds or removes. |
| label | string | Accessible 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