Number Input
Numeric input with increment and decrement buttons.
Installation#
npx @aniui/cli add number-input1
Web preview — components render natively on iOS & Android
import { NumberInput } from "@/components/ui/number-input";
export function MyScreen() {
const [quantity, setQuantity] = useState(1);
return (
<NumberInput
value={quantity}
onValueChange={setQuantity}
min={1}
max={99}
step={1}
/>
);
}Usage#
app/index.tsx
import { NumberInput } from "@/components/ui/number-input";
export function MyScreen() {
const [quantity, setQuantity] = useState(1);
return (
<NumberInput
value={quantity}
onValueChange={setQuantity}
min={1}
max={99}
step={1}
/>
);
}Props#
PropTypeDefault
variant"default" | "ghost""default"size"sm" | "md" | "lg""md"valuenumber-onValueChange(value: number) => void-minnumber0maxnumber999999stepnumber1classNamestring-Also accepts all TextInput props except value and onChangeText.
Accessibility#
- Increment/decrement buttons with
accessibilityValuefor current value. - Min/max boundaries are enforced and announced to assistive technology.
Source#
components/ui/number-input.tsx
import React, { useState, useCallback } from "react";
import { View, TextInput, Pressable, useColorScheme } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { Minus, Plus } from "lucide-react-native";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";
const numberVariants = cva("flex-row items-center rounded-md border", {
variants: {
variant: {
default: "border-input bg-background",
ghost: "border-transparent bg-transparent",
},
size: {
sm: "min-h-9 px-2",
md: "min-h-12 px-3",
lg: "min-h-14 px-4",
},
},
defaultVariants: { variant: "default", size: "md" },
});
export interface NumberInputProps
extends Omit<React.ComponentPropsWithoutRef<typeof TextInput>, "value" | "onChangeText">,
VariantProps<typeof numberVariants> {
className?: string;
value?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
}
export const NumberInput = React.forwardRef<
React.ElementRef<typeof TextInput>,
NumberInputProps
>(function NumberInput(
{ variant, size, className, value: controlledValue, onValueChange, min = 0, max = 999999, step = 1, onBlur, ...props },
ref
) {
const [internal, setInternal] = useState(controlledValue ?? min);
const [text, setText] = useState<string | null>(null);
const value = controlledValue ?? internal;
const dark = useColorScheme() === "dark";
const colors = useThemeColors();
const caret = colors.foreground;
// Matches `colors.muted` exactly, but that's a background-fill role, not one of
// the four text/border roles this migration maps — left hardcoded (see report).
const mutedIcon = colors.muted;
const update = useCallback(
(next: number) => {
const clamped = Math.min(max, Math.max(min, next));
setInternal(clamped);
onValueChange?.(clamped);
},
[min, max, onValueChange]
);
return (
<View className={cn(numberVariants({ variant, size }), className)}>
<Pressable
onPress={() => { setText(null); update(value - step); }}
disabled={value <= min}
accessible={true}
accessibilityRole="button"
accessibilityLabel="Decrease"
className="min-h-10 min-w-10 items-center justify-center"
>
<Minus size={18} color={value <= min ? mutedIcon : caret} strokeWidth={2.5} />
</Pressable>
<TextInput
ref={ref}
className="flex-1 text-foreground text-base p-0"
textAlign="center"
keyboardType="number-pad"
keyboardAppearance={dark ? "dark" : "light"}
selectionColor={caret}
cursorColor={caret}
value={text ?? String(value)}
onChangeText={(t) => {
setText(t);
const n = Number(t);
if (t.trim() !== "" && !Number.isNaN(n)) update(n);
}}
onBlur={(e) => { setText(null); onBlur?.(e); }}
accessibilityLabel="Number value"
{...props}
/>
<Pressable
onPress={() => { setText(null); update(value + step); }}
disabled={value >= max}
accessible={true}
accessibilityRole="button"
accessibilityLabel="Increase"
className="min-h-10 min-w-10 items-center justify-center"
>
<Plus size={18} color={value >= max ? mutedIcon : caret} strokeWidth={2.5} />
</Pressable>
</View>
);
});

