iOS keyboard renders off-center (shifted left) - React Native Modal with InputAccessoryView

8 hours ago 1
ARTICLE AD BOX

enter image description hereI'm building a bottom-sheet modal in React Native (Expo) where a landlord can enter a custom payment amount. The input uses keyboardType="decimal-pad".

By default, iOS renders a floating "Done" pill above the keyboard for decimal-pad keyboards (because they have no return key). I want to suppress that pill, so I followed the common advice: provide an empty InputAccessoryView tied to the TextInput via inputAccessoryViewID. This works — the pill disappears — but it also shifts the keyboard to the left. The left edge of the keyboard touches the screen edge, but the right edge has a noticeable gap. It renders asymmetrically.

Without the InputAccessoryView, the keyboard renders correctly (edge to edge) but the floating "Done" pill returns.

How to recreate with minimal code:

import React, { useState } from 'react'; import { Modal, View, TextInput, Pressable, Text, InputAccessoryView, Platform, StyleSheet, } from 'react-native'; const ACCESSORY_ID = 'emptyAccessory'; export default function App() { const [visible, setVisible] = useState(false); const [value, setValue] = useState(''); return ( <View style={styles.root}> <Pressable onPress={() => setVisible(true)} style={styles.openBtn}> <Text style={{ color: 'white' }}>Open sheet</Text> </Pressable> <Modal visible={visible} transparent animationType="fade" onRequestClose={() => setVisible(false)} > <View style={styles.overlay}> <Pressable style={styles.backdrop} onPress={() => setVisible(false)} /> <View style={styles.sheet}> <TextInput style={styles.input} value={value} onChangeText={setValue} keyboardType="decimal-pad" placeholder="Enter amount" autoFocus inputAccessoryViewID={Platform.OS === 'ios' ? ACCESSORY_ID : undefined} /> </View> </View> </Modal> {/* The culprit — removing this block makes the keyboard center correctly, but iOS's default floating "Done" pill returns. */} {Platform.OS === 'ios' && ( <InputAccessoryView nativeID={ACCESSORY_ID}> <View style={{ width: '100%', height: 0 }} /> </InputAccessoryView> )} </View> ); } const styles = StyleSheet.create({ root: { flex: 1, justifyContent: 'center', padding: 20 }, openBtn: { backgroundColor: '#000', padding: 16, borderRadius: 8, alignItems: 'center', }, overlay: { flex: 1, justifyContent: 'flex-end' }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.4)', }, sheet: { backgroundColor: 'white', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, }, input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 12, padding: 16, fontSize: 24, }, });

Entire code of the file (if needed):

import React, { useState, useCallback, useEffect, useRef } from 'react'; import { Modal, View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator, Platform, TouchableWithoutFeedback, Keyboard, Animated, Easing, Dimensions, InputAccessoryView, } from 'react-native'; import { Colors, Fonts, FontSizes, Radii, Spacing } from '@/constants/theme'; import { formatPrice } from '@/utils/formatting'; const SCREEN_HEIGHT = Dimensions.get('window').height; const EMPTY_ACCESSORY_ID = 'confirmPaymentEmptyAccessory'; type AmountMode = 'full' | 'custom'; interface Props { visible: boolean; onClose: () => void; onConfirm: (confirmedAmount: number) => void; submittedAmount: number; chargeAmount: number; currency?: string; isLoading?: boolean; } export const ConfirmPaymentSheet: React.FC<Props> = ({ visible, onClose, onConfirm, submittedAmount, chargeAmount, currency = 'MVR', isLoading = false, }) => { const [mode, setMode] = useState<AmountMode>('full'); const [rawInput, setRawInput] = useState(''); const [isMounted, setIsMounted] = useState(visible); const slideY = useRef(new Animated.Value(SCREEN_HEIGHT)).current; useEffect(() => { if (visible) { setIsMounted(true); slideY.setValue(SCREEN_HEIGHT); requestAnimationFrame(() => { Animated.timing(slideY, { toValue: 0, duration: 260, easing: Easing.out(Easing.cubic), useNativeDriver: true, }).start(); }); } else if (isMounted) { Animated.timing(slideY, { toValue: SCREEN_HEIGHT, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true, }).start(() => setIsMounted(false)); } }, [visible]); const [kbHeight, setKbHeight] = useState(0); useEffect(() => { const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const showSub = Keyboard.addListener(showEvent, (e) => { const h = e?.endCoordinates?.height ?? 0; if (h >= 100) setKbHeight(h); }); const hideSub = Keyboard.addListener(hideEvent, () => setKbHeight(0)); return () => { showSub.remove(); hideSub.remove(); }; }, []); // Reset when sheet reopens useEffect(() => { if (visible) { setMode('full'); setRawInput(''); } }, [visible]); const parsedCustom = parseFloat(rawInput.replace(/,/g, '')) || 0; const confirmedAmount = mode === 'full' ? submittedAmount : parsedCustom; const diff = confirmedAmount - chargeAmount; const isPartial = mode === 'custom' && parsedCustom > 0 && diff < 0; const isOverpaid = mode === 'custom' && parsedCustom > 0 && diff > 0; const canConfirm = confirmedAmount > 0; const handleModeChange = useCallback((next: AmountMode) => { setMode(next); setRawInput(''); }, []); const handleInputChange = useCallback((text: string) => { // Allow only digits and a single decimal point const clean = text.replace(/[^0-9.]/g, ''); setRawInput(clean); }, []); const handleConfirm = useCallback(() => { if (!canConfirm || isLoading) return; Keyboard.dismiss(); onConfirm(confirmedAmount); }, [canConfirm, isLoading, confirmedAmount, onConfirm]); return ( <> <Modal visible={isMounted} transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent > <TouchableWithoutFeedback onPress={Keyboard.dismiss}> <View style={styles.overlay}> {/* Tap outside to close */} <Pressable style={styles.backdrop} onPress={onClose} /> <Animated.View style={[ styles.sheetWrapper, { transform: [{ translateY: slideY }], marginBottom: kbHeight, }, ]} > <View style={styles.sheet}> {/* ── Header ── */} <View style={styles.header}> <Text style={styles.title}>Confirm payment</Text> <Pressable style={styles.closeBtn} onPress={onClose} accessibilityRole="button" accessibilityLabel="Close" hitSlop={8} > <Text style={styles.closeX}>✕</Text> </Pressable> </View> {/* ── Rent due card ── */} <View style={styles.rentDueCard}> <Text style={styles.rentDueLabel}>Rent Due</Text> <Text style={styles.rentDueAmount}> {formatPrice(chargeAmount, currency)} </Text> </View> {/* ── Tab selector ── */} <View style={styles.tabRow}> <Pressable style={[styles.tab, mode === 'full' && styles.tabActive]} onPress={() => handleModeChange('full')} accessibilityRole="button" > <Text style={[styles.tabText, mode === 'full' && styles.tabTextActive]}> Full amount </Text> </Pressable> <Pressable style={[styles.tab, mode === 'custom' && styles.tabActive]} onPress={() => handleModeChange('custom')} accessibilityRole="button" > <Text style={[styles.tabText, mode === 'custom' && styles.tabTextActive]}> Enter amount </Text> </Pressable> </View> {/* ── Custom amount input ── */} {mode === 'custom' && ( <View style={styles.inputSection}> <View style={styles.amountInputRow}> <Text style={styles.currencyPrefix}>{currency}</Text> <TextInput style={styles.amountInput} value={rawInput} onChangeText={handleInputChange} keyboardType="decimal-pad" placeholder="0" placeholderTextColor={Colors.placeholder} autoFocus returnKeyType="done" onSubmitEditing={Keyboard.dismiss} inputAccessoryViewID={ Platform.OS === 'ios' ? EMPTY_ACCESSORY_ID : undefined } /> </View> {/* Feedback row */} {isPartial && ( <View style={styles.feedbackRow}> <Text style={styles.feedbackText}> {'Balance due is '} <Text style={styles.feedbackAmountRed}> {formatPrice(Math.abs(diff), currency)} </Text> </Text> <Text style={styles.feedbackNote}> Payment will be confirmed as a partial payment. </Text> </View> )} {isOverpaid && ( <View style={styles.feedbackRow}> <Text style={styles.feedbackText}> {'Overpaid amount is '} <Text style={styles.feedbackAmountBlue}> {formatPrice(diff, currency)} </Text> </Text> </View> )} </View> )} {/* ── Confirm button ── */} <Pressable style={({ pressed }) => [ styles.confirmBtn, (!canConfirm || isLoading) && styles.confirmBtnDisabled, pressed && styles.confirmBtnPressed, ]} onPress={handleConfirm} disabled={!canConfirm || isLoading} accessibilityRole="button" > {isLoading ? ( <ActivityIndicator size="small" color={Colors.white} /> ) : ( <Text style={styles.confirmBtnText}> {canConfirm ? `Confirm ${formatPrice(confirmedAmount, currency)}` : 'Confirm payment'} </Text> )} </Pressable> </View> </Animated.View> </View> </TouchableWithoutFeedback> </Modal> {Platform.OS === 'ios' && ( <InputAccessoryView nativeID={EMPTY_ACCESSORY_ID}> <View style={{ width: '100%', height: 0 }} /> </InputAccessoryView> )} </> ); }; const styles = StyleSheet.create({ overlay: { flex: 1, justifyContent: 'flex-end', }, backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.4)', }, sheetWrapper: { width: '100%', }, sheet: { backgroundColor: Colors.white, borderTopLeftRadius: 24, borderTopRightRadius: 24, paddingHorizontal: Spacing['3xl'], paddingTop: Spacing['3xl'], paddingBottom: Platform.OS === 'ios' ? 40 : Spacing['3xl'], gap: Spacing.xl, ...Platform.select({ ios: { shadowColor: '#000', shadowOpacity: 0.12, shadowRadius: 16, shadowOffset: { width: 0, height: -4 }, }, android: { elevation: 12 }, }), }, // ── Header ── header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, title: { fontFamily: Fonts.semibold, fontSize: FontSizes.lg, lineHeight: FontSizes.lg * 1.4, letterSpacing: -0.04, color: Colors.title, }, closeBtn: { width: 36, height: 36, borderRadius: Radii.sm, borderWidth: 1, borderColor: Colors.gray200, backgroundColor: Colors.white, alignItems: 'center', justifyContent: 'center', }, closeX: { fontFamily: Fonts.regular, fontSize: 14, color: Colors.gray700, }, // ── Rent Due ── rentDueCard: { borderRadius: Radii['3xl'], borderWidth: 1, borderColor: Colors.gray200, backgroundColor: Colors.white, paddingVertical: Spacing.xl, paddingHorizontal: Spacing.xl, alignItems: 'center', gap: 6, }, rentDueLabel: { fontFamily: Fonts.light, fontSize: FontSizes.sm, color: Colors.placeholder, }, rentDueAmount: { fontFamily: Fonts.semibold, fontSize: FontSizes['2xl'], letterSpacing: -0.04, color: Colors.title, }, // ── Tabs ── tabRow: { flexDirection: 'row', backgroundColor: Colors.gray100, borderRadius: Radii.md, padding: 3, }, tab: { flex: 1, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: Radii.sm, }, tabActive: { backgroundColor: Colors.white, ...Platform.select({ ios: { shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 4, shadowOffset: { width: 0, height: 1 }, }, android: { elevation: 2 }, }), }, tabText: { fontFamily: Fonts.light, fontSize: FontSizes.base, color: Colors.placeholder, }, tabTextActive: { fontFamily: Fonts.medium, color: Colors.title, }, // ── Amount Input ── inputSection: { gap: Spacing.lg, }, amountInputRow: { flexDirection: 'row', alignItems: 'center', borderRadius: Radii['3xl'], borderWidth: 1, borderColor: Colors.gray200, paddingHorizontal: Spacing.xl, height: 60, gap: Spacing.md, backgroundColor: Colors.white, }, currencyPrefix: { fontFamily: Fonts.light, fontSize: FontSizes.base, color: Colors.placeholder, }, amountInput: { flex: 1, fontFamily: Fonts.medium, fontSize: FontSizes['2xl'], letterSpacing: -0.04, color: Colors.title, padding: 0, }, // ── Feedback ── feedbackRow: { gap: 4, }, feedbackText: { fontFamily: Fonts.light, fontSize: FontSizes.base, color: Colors.title, }, feedbackAmountRed: { fontFamily: Fonts.medium, color: Colors.dangerDark, }, feedbackAmountBlue: { fontFamily: Fonts.medium, color: '#2563EB', }, feedbackNote: { fontFamily: Fonts.light, fontSize: FontSizes.base, color: Colors.subtitle, }, // ── Confirm Button ── confirmBtn: { height: 54, borderRadius: 999, backgroundColor: '#16A34A', alignItems: 'center', justifyContent: 'center', }, confirmBtnDisabled: { opacity: 0.45, }, confirmBtnPressed: { opacity: 0.85, }, confirmBtnText: { fontFamily: Fonts.medium, fontSize: FontSizes.base, color: Colors.white, letterSpacing: 0.1, }, }); export default ConfirmPaymentSheet;

What I've tried

Explicit width: '100%', height: 0 on the inner View — no change.

width: Dimensions.get('window').width on the inner View — no change.

Rendering the InputAccessoryView inside the Modal — no change.

Rendering it as a sibling of the Modal (as shown above) — no change.

Wrapping the inner View in a SafeAreaView — no change.

Using SafeAreaView from react-native-safe-area-context vs react-native — no change.

Removing the InputAccessoryView entirely fixes the keyboard alignment, but the floating "Done" pill returns.

Environment

React Native: [your version — check package.json]

Expo SDK: [your version]

Tested on: iOS Simulator (iPhone 15 Pro, iOS 17.x)

keyboardType="decimal-pad"

Question

Is there a way to:

Move iOS's default floating "Done" pill for decimal-pad keyboards to below the keyboard, AND

Keep the keyboard rendering edge-to-edge (symmetric)?

Or is this a known iOS/RN limitation where I have to accept one or the other? If so, is there a canonical workaround the community has converged on?

Read Entire Article