Skip to content
All components

Free reference

React Native Number Input

A React Native number input is a TextInput with keyboardType number-pad flanked by two stepper buttons, where you strip non-digits yourself because keyboardType never validates, and clamp to min and max on blur rather than on every keystroke.

Installation

None. This is a TextInput, two Pressables and some state, all from react-native itself.

Usage

import { useCallback, useEffect, useId, useRef, useState } from 'react';
import {
  InputAccessoryView,
  Keyboard,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';

type NumberInputProps = {
  value: number;
  onChange: (next: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Allow one decimal separator. Integers only when false. */
  decimals?: boolean;
  label?: string;
};

const HOLD_DELAY = 400; // ms held before the repeat starts
const HOLD_INTERVAL = 80; // ms between repeats

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

// How many decimals a step carries: 1 -> 0, 0.5 -> 1, 0.0005 -> 4. Steps get
// snapped back to that many places, because 0.1 + 0.2 is 0.30000000000000004.
// A hard-coded 3-decimal round would make any step under 0.001 a no-op.
const decimalsOf = (n: number) => {
  const exponential = n.toExponential();
  const cut = exponential.indexOf('e');
  const power = Number(exponential.slice(cut + 1));
  const fraction = exponential.slice(0, cut).split('.')[1] ?? '';
  return Math.min(Math.max(fraction.length - power, 0), 15);
};

export function NumberInput({
  value,
  onChange,
  min = 0,
  max = 99,
  step = 1,
  decimals = false,
  label,
}: NumberInputProps) {
  // What the field shows, kept separate from the number the parent owns so a
  // half-typed entry ("", "-", "1.") survives until the user is done.
  const [text, setText] = useState(String(value));
  const [focused, setFocused] = useState(false);

  // Repeats read the newest number, not the one captured when the hold began.
  const valueRef = useRef(value);
  useEffect(() => {
    valueRef.current = value;
    if (!focused) setText(String(value));
  }, [value, focused]);

  // One accessory bar per instance, so two inputs on a screen do not share it.
  const instanceId = useId();
  const accessoryId = `number-input-${instanceId}`;

  const timers = useRef<{
    timeout?: ReturnType<typeof setTimeout>;
    interval?: ReturnType<typeof setInterval>;
  }>({});

  const stopHold = useCallback(() => {
    if (timers.current.timeout) clearTimeout(timers.current.timeout);
    if (timers.current.interval) clearInterval(timers.current.interval);
    timers.current = {};
  }, []);

  // Unmounting mid-hold would otherwise leave the interval running.
  useEffect(() => stopHold, [stopHold]);

  const precision = decimalsOf(step);

  const nudge = useCallback(
    (direction: 1 | -1) => {
      const raw = valueRef.current + direction * step;
      const next = clamp(Number(raw.toFixed(precision)), min, max);
      if (next === valueRef.current) return;
      valueRef.current = next;
      setText(String(next));
      onChange(next);
    },
    [max, min, onChange, precision, step],
  );

  const startHold = useCallback(
    (direction: 1 | -1) => {
      nudge(direction);
      timers.current.timeout = setTimeout(() => {
        timers.current.interval = setInterval(
          () => nudge(direction),
          HOLD_INTERVAL,
        );
      }, HOLD_DELAY);
    },
    [nudge],
  );

  // keyboardType is a hint, not a validator: hardware keyboards, paste and a
  // few Android IMEs all deliver characters this control cannot use.
  const sanitize = useCallback(
    (raw: string) => {
      let out = raw.replace(decimals ? /[^0-9.-]/g : /[^0-9-]/g, '');
      const negative = min < 0 && out.startsWith('-');
      out = out.replace(/-/g, '');
      if (decimals) {
        const parts = out.split('.');
        if (parts.length > 1) out = `${parts.shift()}.${parts.join('')}`;
      }
      return negative ? `-${out}` : out;
    },
    [decimals, min],
  );

  const handleChangeText = (raw: string) => {
    const next = sanitize(raw);
    setText(next);
    const parsed = decimals ? parseFloat(next) : parseInt(next, 10);
    // "", "-" and "." are legal keystrokes on the way to a number.
    if (Number.isNaN(parsed)) return;
    // Publish only in-range values while typing. Out of range is clamped on
    // blur instead, so the 5 on the way to 50 is not eaten by min = 10.
    if (parsed >= min && parsed <= max) onChange(parsed);
  };

  const commit = () => {
    setFocused(false);
    const parsed = decimals ? parseFloat(text) : parseInt(text, 10);
    // Typed entry is clamped but never rounded: 1.2345 survives the blur.
    const next = Number.isNaN(parsed) ? value : clamp(parsed, min, max);
    setText(String(next));
    if (next !== value) onChange(next);
  };

  // Neither pad has a minus key, on either platform, so a negative range needs
  // a keyboard that can type one.
  const keyboardType =
    min < 0
      ? Platform.OS === 'ios'
        ? 'numbers-and-punctuation' // iOS only
        : 'numeric' // Android: TYPE_CLASS_NUMBER plus the signed flag
      : decimals
        ? 'decimal-pad'
        : 'number-pad';

  const atMin = value <= min;
  const atMax = value >= max;

  return (
    <View>
      {label ? <Text style={styles.label}>{label}</Text> : null}

      <View style={styles.row}>
        <Pressable
          accessibilityRole="button"
          accessibilityLabel={`Decrease ${label ?? 'value'}`}
          accessibilityState={{ disabled: atMin }}
          disabled={atMin}
          hitSlop={12}
          onPressIn={() => startHold(-1)}
          onPressOut={stopHold}
          style={({ pressed }) => [
            styles.step,
            pressed && styles.stepPressed,
            atMin && styles.stepDisabled,
          ]}
        >
          <Text style={styles.stepGlyph}>-</Text>
        </Pressable>

        {/* returnKeyType and onSubmitEditing are the Android path: its number
            keyboards have a return key, the iOS pads do not. */}
        <TextInput
          value={text}
          onChangeText={handleChangeText}
          onFocus={() => setFocused(true)}
          onBlur={commit}
          onSubmitEditing={Keyboard.dismiss}
          keyboardType={keyboardType}
          returnKeyType="done"
          selectTextOnFocus
          maxLength={12}
          accessibilityLabel={label}
          inputAccessoryViewID={Platform.OS === 'ios' ? accessoryId : undefined}
          style={styles.field}
        />

        <Pressable
          accessibilityRole="button"
          accessibilityLabel={`Increase ${label ?? 'value'}`}
          accessibilityState={{ disabled: atMax }}
          disabled={atMax}
          hitSlop={12}
          onPressIn={() => startHold(1)}
          onPressOut={stopHold}
          style={({ pressed }) => [
            styles.step,
            pressed && styles.stepPressed,
            atMax && styles.stepDisabled,
          ]}
        >
          <Text style={styles.stepGlyph}>+</Text>
        </Pressable>
      </View>

      {/* The iOS number pad has no return key, so give it a way out. */}
      {Platform.OS === 'ios' ? (
        <InputAccessoryView nativeID={accessoryId}>
          <View style={styles.accessory}>
            <Pressable
              accessibilityRole="button"
              hitSlop={8}
              onPress={Keyboard.dismiss}
            >
              <Text style={styles.accessoryText}>Done</Text>
            </Pressable>
          </View>
        </InputAccessoryView>
      ) : null}
    </View>
  );
}

export default function NumberInputDemo() {
  const [quantity, setQuantity] = useState(1);
  const [price, setPrice] = useState(12.5);

  return (
    <View style={styles.screen}>
      <NumberInput
        label="Quantity"
        value={quantity}
        onChange={setQuantity}
        min={1}
        max={99}
      />
      <NumberInput
        label="Unit price"
        value={price}
        onChange={setPrice}
        min={0}
        max={999}
        step={0.5}
        decimals
      />
      <Text style={styles.total}>
        Total: ${(quantity * price).toFixed(2)}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 24, gap: 28, backgroundColor: '#fff', flex: 1 },
  label: { fontSize: 13, fontWeight: '600', color: '#666', marginBottom: 8 },
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    alignSelf: 'flex-start',
    borderWidth: 1,
    borderColor: '#e5e5e5',
    borderRadius: 12,
    overflow: 'hidden',
  },
  step: {
    width: 48,
    height: 48,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#fafafa',
  },
  stepPressed: { backgroundColor: '#ededed' },
  stepDisabled: { opacity: 0.35 },
  stepGlyph: { fontSize: 22, lineHeight: 24, color: '#111' },
  field: {
    width: 88,
    height: 48,
    fontSize: 17,
    fontVariant: ['tabular-nums'],
    textAlign: 'center',
    color: '#111',
    padding: 0,
    includeFontPadding: false,
  },
  accessory: {
    alignItems: 'flex-end',
    backgroundColor: '#f2f2f7',
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: '#c6c6c8',
    paddingHorizontal: 16,
    paddingVertical: 10,
  },
  accessoryText: { fontSize: 17, fontWeight: '600', color: '#007aff' },
  total: { fontSize: 15, color: '#111' },
});

How it works

There are two states here, not one. The parent owns the number, the component owns the string in the field. Someone typing 12 passes through 1, someone clearing the field passes through empty, and neither is worth writing back. The string is the draft, the number is the commit.

Every keystroke is sanitised before it is displayed: non-digits stripped, extra decimal points collapsed, a leading minus kept only when min is negative. keyboardType chooses which keys are offered, it does not police what arrives, and paste, autofill and any hardware keyboard walk straight past it.

While the field has focus, only in-range values reach the parent; out-of-range text stays on screen and is clamped in commit, on blur. Clamp every keystroke instead and, with min = 10, the 5 on the way to 50 becomes 10 and the caret jumps.

The steppers repeat by hand. onPressIn takes one step, then arms a 400ms timeout that starts an 80ms interval; onPressOut and unmount clear both. The interval reads the value from a ref, not the prop, because the closure from when the hold began would keep adding to a stale number.

Gotchas

On iOS, numeric and decimal-pad are the same keyboard

Both map to UIKeyboardTypeDecimalPad, and number-pad is that pad without a separator. None of the three has a return key or a minus key. The digits-and-punctuation layout is a fourth value, numbers-and-punctuation, iOS only, and the one this component switches to when min is negative. Android does keep them apart: numeric carries the decimal and signed flags, decimal-pad only decimal, number-pad neither.

The iOS number pad has no return key

So returnKeyType and onSubmitEditing are the Android path only, and on iOS the keyboard sits there over your submit button. The component above ships an InputAccessoryView with a Done button that calls Keyboard.dismiss(), behind a Platform.OS check because Android already has the back gesture.

Decimal steps drift

A step of 0.1 reaches 0.30000000000000004 in three taps, and that number lands in your cart total. Round after every step, but read the precision off step rather than hard-coding it: a fixed three-decimal round makes step={0.0005} a no-op, buttons that look fine and do nothing. Typed entry is clamped and never rounded, so 1.2345 survives a blur, though the next press snaps it to the step's precision. Or hold the value in the smallest unit (cents, grams) and format for display.

A bare plus sign tells a screen reader nothing

A Pressable around the character + is announced as "plus". Give each stepper an explicit accessibilityLabel and an accessibilityState so the disabled edge is audible. For the native swipe-to-change gesture, put accessibilityRole="adjustable" and increment/decrement actions on the row.

How do I allow only numbers in a React Native TextInput?

Strip them yourself in onChangeText. keyboardType="number-pad" only changes which keyboard appears, so pasted, autofilled and hardware-keyboard text can still contain letters. Run the incoming string through raw.replace(/[^0-9]/g, ''), keep it in state and pass it back as the input's value; because the input is controlled, rejected characters never reach the screen.

Why does my React Native numeric keyboard have no return key?

On iOS, number-pad and decimal-pad are 10-key pads with no return key, so onSubmitEditing never fires from a touch. Add a dismiss path: render an InputAccessoryView with a Done button that calls Keyboard.dismiss(), or dismiss on a tap outside the field. keyboardType="numeric" will not help: on iOS it maps to that same decimal pad. The only iOS layout with a return key on it is numbers-and-punctuation.

Should I clamp to min and max while the user is typing?

No, clamp on blur. Clamp every keystroke with a minimum of 10 and the 5 someone types on the way to 50 is rewritten to 10 while the caret jumps. Let the draft string hold an out-of-range number, publish only in-range values while the field has focus, then clamp once when focus leaves. Stepper buttons are the exception: they land on whole values, so clamp those immediately and disable at the edge.

Do I need a package for a number input in React Native?

No. A stepper with typed entry, clamping, hold-to-repeat and decimal support is the component on this page: a couple of hundred lines of core React Native. Reach for a dependency only for the genuinely hard part, locale-aware currency formatting, and even then a formatting library beats an input library.

Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.