Number Input
pnpm dlx shadcn@latest add …/r/number-input.jsonAn accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.
- Quantity
- Price
import { NumberInput } from "@/components/ui/number-input"
export function Quantity() {
return (
<>
<label htmlFor="quantity">Quantity</label>
<NumberInput id="quantity" defaultValue={1} min={1} max={99} />
</>
)
}"use client";
import { NumberInput } from "@/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";
type Size = "sm" | "md" | "lg";
/*
* A quantity and a price side by side, named underneath, with the size in the top-right corner. An empty
* row at the bottom, as tall as the setting's row, keeps them centred.
*/
export default function NumberInputFields() {
const id = useId();
const [size, setSize] = useState<Size>("md");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
Size
</label>
<Select value={size} onValueChange={(value) => setSize(value as Size)}>
<SelectTrigger id={`${id}-size`} size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm">Small</SelectItem>
<SelectItem value="md">Medium</SelectItem>
<SelectItem value="lg">Large</SelectItem>
</SelectContent>
</Select>
</div>
<ul className="flex flex-1 flex-wrap items-center justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<NumberInput aria-label="Quantity" defaultValue={1} min={1} max={99} size={size} className="w-32" />
<span className="text-xs text-muted-foreground">Quantity</span>
</li>
<li className="flex flex-col items-center gap-2">
<NumberInput
aria-label="Price"
defaultValue={24}
min={0}
step={0.5}
largeStep={10}
locale="en-US"
format={{ style: "currency", currency: "USD" }}
size={size}
className="w-40"
/>
<span className="text-xs text-muted-foreground">Price</span>
</li>
</ul>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority lucide-react motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
"use client"; import { Button } from "@/components/ui/button"; import { blur, duration, ease, spring, swap } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { MinusIcon, PlusIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ComponentProps, type KeyboardEvent, type MouseEvent, type PointerEvent, useEffect, useId, useRef, useState, } from "react"; /* * Number Input: a number field with step buttons (owner, 2026-10-16). It follows the WAI-ARIA spinbutton * pattern: a text input with role="spinbutton", ArrowUp/ArrowDown step, PageUp/PageDown take a big step, * Home/End go to min/max. Typed text is read when you leave the field or press Enter, then clamped and * formatted; a value in range also updates as you type. * - Motion (.agents/rules/motion.md): when a step changes the number, it rolls like an odometer. Each digit * that changed rolls a whole line: the old one travels out of the field as the new one travels in from the * other edge (up for up, down for down) on spring.snappy, sharpening through the blur over duration.slow so * the roll reads smooth rather than flickering (owner, 2026-10-21: "more noticeable"). Places set off 0.05s * apart from the right, so 19 → 20 turns the ones, then the tens. The field's top and bottom edges fade, so * digits roll in and out of sight softly. The others stay put; typing doesn't animate. While it rolls, the * input's own text is transparent and a copy is drawn over it, character by character, with tabular figures * so nothing jiggles; then the input's text shows again. With reduced motion the number just changes. * - The step buttons are our ghost icon Buttons inside the field. They aren't in the tab order (the keys do * the same) and don't take focus. Press and hold steps once, then repeats. * - Formatting is optional, through Intl.NumberFormat (`format`, `locale`). A form gets the plain number * through a hidden input. * - The field looks like Input: control heights, Input's border, its focus ring around the whole field. */ /** Press and hold: one step at once, then after `duration.slower`, one every `duration.instant` */ const HOLD_DELAY = duration.slower * 1000; const HOLD_REPEAT = duration.instant * 1000; /** Places set off one after another from the right: motion.md's list stagger */ const STAGGER = 0.05; /** * After this long without another step, the rolling copy hands back to the input's own text: the roll's * blur (duration.slow) plus room for the spring to land and a few places' stagger */ const ROLL_MS = duration.slow * 2 * 1000; /** The roller's top and bottom edges fade, so digits roll in and out of sight */ const ROLL_FADE = "linear-gradient(to bottom, transparent, black 30%, black 70%, transparent)"; const numberInputVariants = cva( [ "relative flex w-full min-w-0 items-center rounded-lg border border-input bg-transparent px-1 text-sm text-foreground shadow-xs", "transition duration-(--duration-fast) ease-out", // The ring goes round the whole field while its input has focus, the way Input's does "outline-ring has-focus-visible:border-ring has-focus-visible:outline-(length:--focus-ring-width) has-focus-visible:outline-offset-(--focus-ring-offset)", "data-invalid:border-destructive data-invalid:outline-destructive", "data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)", ], { variants: { size: { sm: "h-(--size-control-sm) rounded-md", md: "h-(--size-control-md)", lg: "h-(--size-control-lg)", }, }, defaultVariants: { size: "md" }, }, ); // The buttons sit 3px inside the field's border at every size const buttonSizes = { sm: "size-6", md: "size-7", lg: "size-8" } as const; const iconSizes = { sm: "size-(--size-icon-sm)", md: "size-(--size-icon-md)", lg: "size-(--size-icon-md)" }; /** Decimal places in a number, so 0.1 + 0.2 steps to 0.3 */ function decimalsOf(value: number) { const [, fraction = "", exponent] = /^-?\d*\.?(\d*)(?:e([+-]\d+))?$/.exec(String(value)) ?? []; return Math.max(0, fraction.length - Number(exponent ?? 0)); } /** * Reads typed text in the field's locale: group separators, currency signs and spaces are ignored. Empty * text is null, and text with no digits is NaN (so the field goes back to the last number). */ function parseNumber(text: string, locale: string | undefined, format: Intl.NumberFormatOptions | undefined) { const trimmed = text.trim(); if (!trimmed) return null; // Without a format the field shows plain numbers (1234.5), so it reads them the same way const parts = new Intl.NumberFormat(format ? locale : "en-US").formatToParts(-12345.6); const group = parts.find((part) => part.type === "group")?.value ?? ","; const decimal = parts.find((part) => part.type === "decimal")?.value ?? "."; // A minus sign anywhere (-$5, $-5, 5-, the typographic −) or accounting brackets, (5.00), is negative const negative = /[-−]/.test(trimmed) || /^\(.*\)$/.test(trimmed); const digits = trimmed .split(group) .join("") .split(decimal) .join(".") .replace(/[^\d.]/g, ""); if (!/\d/.test(digits)) return Number.NaN; let number = Number(digits); if (!Number.isFinite(number)) return Number.NaN; if (negative) number = -number; // A percent field shows 25% for 0.25 if (format?.style === "percent") number /= 100; return number; } type Roll = { direction: 1 | -1; animate: boolean }; /** A digit at rest, and one a whole line above or below it, blurred */ const rest = { y: "0%", opacity: 1, filter: blur.shown }; const away = (side: 1 | -1) => ({ y: `${side * 100}%`, opacity: 0, filter: blur.hidden }); /** Going up, the new digit comes from below and the old one leaves upwards; going down, the other way */ const rollVariants = { enter: ({ direction, animate }: Roll) => (animate ? away(direction) : swap.animate), center: rest, exit: ({ direction, animate }: Roll) => animate ? // The element's own transition: the old digit leaves with the same stagger the new one arrives on away(direction === 1 ? -1 : 1) : { opacity: 0, transition: { duration: 0 } }, }; /** The roll for a place: the travel on spring.snappy, the blur and fade on a smooth duration.slow, staggered */ const rollTransition = (place: number) => { const delay = Math.max(0, place) * STAGGER; return { duration: duration.slow, ease: ease.out, delay, y: { ...spring.snappy, delay } }; }; /** * The rolling copy of the field's text. Places are counted from the right, so 19 → 20 rolls both digits * and 20 → 21 only the last. Each place stacks the old and new character in one cell while they swap. */ function Roller({ text, roll }: { text: string; roll: Roll | null }) { const custom = roll ?? { direction: 1, animate: false }; const chars = Array.from(text); return ( <span aria-hidden data-slot="number-input-roll" className={cn( "pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden px-2 tabular-nums", !roll && "invisible", )} style={{ maskImage: ROLL_FADE, WebkitMaskImage: ROLL_FADE }} > {/* A place that goes (21 → 9) pops out of the row so the rest settle at once */} <AnimatePresence initial={false} mode="popLayout" custom={custom}> {chars.map((char, index) => { const place = chars.length - 1 - index; return ( <motion.span key={place} custom={custom} variants={rollVariants} initial="enter" animate="center" exit="exit" transition={rollTransition(place)} className="inline-grid" > <AnimatePresence initial={false} custom={custom}> <motion.span key={char} custom={custom} variants={rollVariants} initial="enter" animate="center" exit="exit" transition={rollTransition(place)} className="col-start-1 row-start-1 whitespace-pre" > {char} </motion.span> </AnimatePresence> </motion.span> ); })} </AnimatePresence> </span> ); } export type NumberInputProps = Omit< ComponentProps<"input">, "value" | "defaultValue" | "onChange" | "type" | "size" | "min" | "max" | "step" > & VariantProps<typeof numberInputVariants> & { /** The number, when you control it; null when the field is empty */ value?: number | null; /** The number at first */ defaultValue?: number | null; /** Called with the new number, or null when the field is emptied */ onValueChange?: (value: number | null) => void; /** The lowest number; Home goes here */ min?: number; /** The highest number; End goes here */ max?: number; /** How much ArrowUp, ArrowDown and the buttons change it */ step?: number; /** How much PageUp and PageDown change it; ten steps if left out */ largeStep?: number; /** Intl.NumberFormat options, for example { style: "currency", currency: "USD" } */ format?: Intl.NumberFormatOptions; /** The locale to format and read numbers in; the browser's if left out */ locale?: string; /** The increment button's accessible name */ incrementLabel?: string; /** The decrement button's accessible name */ decrementLabel?: string; }; /** * A number field with step buttons. `className` styles the field; other props, including `id` and `ref`, * go to its input. Give it a visible label (`<label htmlFor>`). */ function NumberInput({ className, size, value, defaultValue, onValueChange, min, max, step = 1, largeStep, format, locale, incrementLabel = "Increase", decrementLabel = "Decrease", id, name, form, disabled = false, readOnly = false, "aria-invalid": ariaInvalid, onKeyDown, onBlur, ...props }: NumberInputProps) { const fallbackId = useId(); const inputId = id ?? fallbackId; // Mirror the value, controlled or not, so stepping and the buttons always start from the latest number const [uncontrolled, setUncontrolled] = useState<number | null>(defaultValue ?? null); const current = value === undefined ? uncontrolled : value; // The text while the user types; null shows the formatted number const [draft, setDraft] = useState<string | null>(null); const [roll, setRoll] = useState<{ direction: 1 | -1; count: number } | null>(null); const reduce = useReducedMotion() ?? false; const latest = useRef(current); latest.current = current; const formatter = format ? new Intl.NumberFormat(locale, format) : null; const display = (number: number | null) => number === null ? "" : formatter ? formatter.format(number) : String(number); const text = draft ?? display(current); const clamp = (number: number) => Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, number)); const invalid = ariaInvalid === true || ariaInvalid === "true"; function change(next: number | null) { if (Object.is(next, latest.current)) return; latest.current = next; if (value === undefined) setUncontrolled(next); onValueChange?.(next); } /** A change made by stepping, which rolls the number. Returns false when nothing changed (at a bound). */ function stepTo(next: number, direction: 1 | -1) { setDraft(null); if (next === latest.current) return false; if (!reduce) setRoll((last) => ({ direction, count: (last?.count ?? 0) + 1 })); change(next); return true; } function stepBy(direction: 1 | -1, amount = step) { if (disabled || readOnly) return false; const typed = draft === null ? null : parseNumber(draft, locale, format); const base = typed === null || Number.isNaN(typed) ? latest.current : typed; // From empty, the first step shows 0 (or the nearest bound) if (base === null) return stepTo(clamp(0), direction); const places = Math.max(decimalsOf(base), decimalsOf(amount)); return stepTo(clamp(Number((base + direction * amount).toFixed(places))), direction); } function goTo(target: number) { if (disabled || readOnly) return; const from = latest.current ?? target; stepTo(target, target >= from ? 1 : -1); } /** Reads what was typed: clamped and formatted, or back to the last number when it isn't one */ function commit() { if (draft === null) return; const typed = parseNumber(draft, locale, format); setDraft(null); if (typed === null) change(null); else if (!Number.isNaN(typed)) change(clamp(typed)); } // The rolling copy hands back to the input's own text once the steps stop useEffect(() => { if (!roll) return; const timer = setTimeout(() => setRoll(null), ROLL_MS); return () => clearTimeout(timer); }, [roll]); // Press and hold. The timers call the latest stepBy, so each repeat starts from the latest number. const stepRef = useRef(stepBy); stepRef.current = stepBy; const hold = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const pointerStep = useRef(false); const stopHold = () => clearTimeout(hold.current); useEffect(() => () => clearTimeout(hold.current), []); function startHold(direction: 1 | -1) { stopHold(); if (!stepRef.current(direction)) return; const repeat = (wait: number) => { hold.current = setTimeout(() => { if (stepRef.current(direction)) repeat(HOLD_REPEAT); }, wait); }; repeat(HOLD_DELAY); } const atMin = current !== null && min !== undefined && current <= min; const atMax = current !== null && max !== undefined && current >= max; const stepButton = (direction: 1 | -1) => ({ variant: "ghost" as const, size: "icon-sm" as const, // The keys do the same, so the buttons stay out of the tab order (WAI-ARIA spinbutton) tabIndex: -1, "aria-label": direction > 0 ? incrementLabel : decrementLabel, "aria-controls": inputId, disabled: disabled || readOnly || (direction > 0 ? atMax : atMin), className: cn("text-muted-foreground hover:text-foreground", buttonSizes[size ?? "md"]), // Pressing doesn't take focus from the input onMouseDown: (event: MouseEvent<HTMLButtonElement>) => event.preventDefault(), onPointerDown: (event: PointerEvent<HTMLButtonElement>) => { if (event.button !== 0) return; event.preventDefault(); pointerStep.current = true; startHold(direction); }, onPointerUp: stopHold, onPointerLeave: stopHold, onPointerCancel: stopHold, onClick: (event: MouseEvent<HTMLButtonElement>) => { // A pointer already stepped on press; a click from the keyboard or a screen reader steps here if (event.detail === 0 || !pointerStep.current) stepRef.current(direction); pointerStep.current = false; }, }); return ( <div data-slot="number-input" data-size={size ?? "md"} data-invalid={invalid || undefined} data-disabled={disabled || undefined} className={cn(numberInputVariants({ size }), className)} > <Button {...stepButton(-1)}> <MinusIcon aria-hidden className={iconSizes[size ?? "md"]} /> </Button> <div className="relative h-full min-w-0 flex-1"> <input id={inputId} type="text" role="spinbutton" inputMode="decimal" autoComplete="off" autoCorrect="off" spellCheck={false} aria-valuenow={current ?? undefined} aria-valuemin={min} aria-valuemax={max} aria-valuetext={formatter && current !== null ? formatter.format(current) : undefined} aria-invalid={ariaInvalid} disabled={disabled} readOnly={readOnly} data-slot="number-input-input" value={text} onChange={(event) => { const next = event.target.value; setDraft(next); setRoll(null); // A number in range updates as you type; anything else waits until you leave the field const typed = parseNumber(next, locale, format); if (typed === null) change(null); else if (!Number.isNaN(typed) && typed === clamp(typed)) change(typed); }} onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { onKeyDown?.(event); if (event.defaultPrevented || disabled || readOnly) return; const big = largeStep ?? step * 10; switch (event.key) { case "ArrowUp": stepBy(1); break; case "ArrowDown": stepBy(-1); break; case "PageUp": stepBy(1, big); break; case "PageDown": stepBy(-1, big); break; case "Home": if (min === undefined) return; goTo(min); break; case "End": if (max === undefined) return; goTo(max); break; case "Enter": // Read the typed number before the form submits commit(); return; default: return; } event.preventDefault(); }} onBlur={(event) => { commit(); onBlur?.(event); }} className={cn( "size-full min-w-0 bg-transparent px-2 text-center tabular-nums outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed", roll && !reduce && "text-transparent", )} {...props} /> <Roller text={text} roll={roll && !reduce ? { direction: roll.direction, animate: true } : null} /> </div> <Button {...stepButton(1)}> <PlusIcon aria-hidden className={iconSizes[size ?? "md"]} /> </Button> {name ? ( <input type="hidden" name={name} form={form} value={current ?? ""} disabled={disabled} /> ) : null} </div> ); } export { NumberInput, numberInputVariants };Update the import paths to match your project.
Examples
Percent
A value from 0 to 1 shown as a percent, in steps of 5%. Page Up and Page Down take bigger steps; Home and End go to the ends.
Up to 100%, in steps of 5%.
import { NumberInput } from "@/components/ui/number-input";
import { useId } from "react";
/** A percent between 0 and 1 in steps of 5%. Home and End go to 0% and 100%. */
export default function NumberInputPercent() {
const id = useId();
return (
<div className="grid w-full max-w-xs gap-2">
<label htmlFor={id} className="text-sm font-medium">
Discount
</label>
<NumberInput
id={id}
defaultValue={0.15}
min={0}
max={1}
step={0.05}
largeStep={0.25}
locale="en-US"
format={{ style: "percent" }}
aria-describedby={`${id}-hint`}
/>
<p id={`${id}-hint`} className="text-sm text-muted-foreground">
Up to 100%, in steps of 5%.
</p>
</div>
);
}Disabled
import { NumberInput } from "@/components/ui/number-input";
import { useId } from "react";
export default function NumberInputDisabled() {
const id = useId();
return (
<div className="grid w-full max-w-xs gap-2">
<label htmlFor={id} className="text-sm font-medium">
Seats
</label>
<NumberInput id={id} defaultValue={5} min={1} disabled />
</div>
);
}API reference
A spinbutton with step buttons. className styles the field; every other native input prop, id and ref included, goes to the input. Typed text is read when you leave the field or press Enter.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | number | null | – | The number, when you control it. null is an empty field. |
defaultValue? | number | null | null | The number at first. |
onValueChange? | (value: number | null) => void | – | Called with the new number after a step, after typing a number in range, or when you leave the field. |
min? | number | – | The lowest number. Home goes here. |
max? | number | – | The highest number. End goes here. |
step? | number | 1 | How much the arrow keys and the buttons change it. |
largeStep? | number | step × 10 | How much Page Up and Page Down change it. |
format? | Intl.NumberFormatOptions | – | How the number is shown, for example { style: "currency", currency: "USD" }. Typed text is read back the same way. |
locale? | string | – | The locale for formatting and reading numbers. Set it when you render on the server, so the server and the browser agree. |
size? | "sm" | "md" | "lg" | "md" | 32, 36 or 40px tall, matching Input. |
name? | string | – | Submits the plain number with a form, through a hidden input. |
incrementLabel? | string | "Increase" | The plus button's accessible name. |
decrementLabel? | string | "Decrease" | The minus button's accessible name. |
aria-invalid? | boolean | – | Marks the number as wrong: the border and focus ring turn destructive. |
disabled? | boolean | false | Prevents changes. |
readOnly? | boolean | false | Shows the number without letting it change. |
Related components
An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.
An accessible React slider for Next.js and Tailwind: one value or a range, with a thumb that grows into a pill showing its value as you reach for it.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.