AniUI

Number Input

Numeric input with increment and decrement buttons.

Installation#

npx @aniui/cli add number-input
1
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"
value
number
-
onValueChange
(value: number) => void
-
min
number
0
max
number
999999
step
number
1
className
string
-

Also accepts all TextInput props except value and onChangeText.

Accessibility#

  • Increment/decrement buttons with accessibilityValue for 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>
  );
});