Free referenceMehdi Davoodi
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.