Mehdi Davoodi10 min read
React Native textarea: the auto-growing multiline TextInput
There is no TextArea in React Native. You use TextInput multiline, then fix auto-growing height, Android padding and the character counter.
There is no <TextArea> in React Native. A textarea is
<TextInput multiline />, and that one prop is where the
documentation stops and the real work starts: the box does not grow as you
type, and on Android the text floats in the middle of it and sits a few pixels
lower than the same style renders on iOS.
Working code for an auto-growing textarea with a character counter, plus the platform differences that make the naive version look broken on one OS and fine on the other. Everything here targets React Native 0.81 and up (Expo SDK 54 and later), with the New Architecture on.
Is there a TextArea component in React Native?
No. React Native ships one text field, TextInput, and
multiline turns it into a textarea. On iOS that swaps the
underlying native view from a UITextField to a
UITextView; on Android it lifts the single-line constraint on
the EditText. That swap is the source of nearly every quirk
below, because the two multiline views were never designed to match each
other.
import { TextInput } from 'react-native';
<TextInput
multiline
value={value}
onChangeText={setValue}
placeholder="Write something"
style={{ minHeight: 96, padding: 12 }}
/>
That renders. It also does none of the things people mean when they say textarea. Here is what is missing, in the order it will bite you.
Why does the same textarea look different on iOS and Android?
Two real differences, plus one myth that will cost you five points if you believe it.
Vertical alignment. On Android, when the input is taller than
its text, the text is centered vertically. A four-line-tall empty box shows
the placeholder floating in the middle. iOS starts at the top. The fix is the
Android-only style property textAlignVertical: 'top', which you
set in the style object, not as a prop. Newer React Native also accepts
verticalAlign: 'top' as the modern alias, and the two do the same
job.
Font padding. Android's text rendering reserves extra space
above and below the glyphs for font metrics. Set
includeFontPadding: false and the first line lands where you
expect, matching iOS.
The 5pt myth. There is a lot of advice telling you that iOS
adds about 5 points of line-fragment padding inside UITextView,
and that you should subtract it on iOS. Do not. React Native's multiline text
view sets textContainer.lineFragmentPadding = 0 in its own
initializer, and has for many versions, precisely so the padding you write is
the padding you get. Subtract 5 today and you ship the exact mismatch the
advice was written to prevent: text inset 11 points on iOS and 16 on Android.
Write the same paddingHorizontal for both platforms, then look at
it on a device.
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
input: {
fontSize: 16,
lineHeight: 22,
color: '#111',
paddingTop: 12,
paddingBottom: 12,
paddingHorizontal: 16, // same number on both platforms, on purpose
textAlignVertical: 'top', // Android: start at the top, not the middle
includeFontPadding: false, // Android: drop the font-metrics padding
},
});
One more: a multiline TextInput has no useful intrinsic height on
iOS, so give it minHeight. numberOfLines (and its
rows alias) is a sizing hint, and a hint is the wrong tool for a
box whose height you are about to drive from measured content. The two will
disagree the moment someone types.
How do you make a React Native textarea grow as the user types?
You listen to onContentSizeChange, which fires with the measured
height of the text, and you drive the input's height from it, clamped between
a minimum and a maximum. That is the whole technique. The part people get
wrong is the clamp.
If you write setHeight(e.nativeEvent.contentSize.height) raw,
you push an unrounded float straight back into layout, and the next
measurement can come back a hair different: 96.00002 instead of 96. Every
one of those is a fresh state value, a fresh layout pass and another event,
and on Android the rounding across passes is enough to keep it chattering
while nothing on screen moves. Round the value, clamp it, and bail out when
it has not changed.
Here is the component we actually use. It grows from 96 to 220 points, then scrolls internally, and it carries a character counter.
import { useCallback, useState } from 'react';
import {
NativeSyntheticEvent,
StyleSheet,
Text,
TextInput,
TextInputContentSizeChangeEventData,
View,
} from 'react-native';
const MIN_HEIGHT = 96;
const MAX_HEIGHT = 220;
const MAX_CHARS = 280;
type Props = {
value: string;
onChangeText: (next: string) => void;
placeholder?: string;
onFocus?: () => void;
onBlur?: () => void;
onCappedChange?: (capped: boolean) => void;
};
export function TextArea({
value,
onChangeText,
placeholder,
onFocus,
onBlur,
onCappedChange,
}: Props) {
const [height, setHeight] = useState(MIN_HEIGHT);
const [capped, setCapped] = useState(false);
const handleContentSizeChange = useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
const measured = Math.round(e.nativeEvent.contentSize.height);
const next = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, measured));
setHeight((prev) => (prev === next ? prev : next));
const hitCeiling = measured >= MAX_HEIGHT;
setCapped(hitCeiling);
onCappedChange?.(hitCeiling);
},
[onCappedChange],
);
const used = [...value].length;
const remaining = MAX_CHARS - used;
return (
<View style={styles.field}>
<TextInput
multiline
value={value}
onChangeText={onChangeText}
onContentSizeChange={handleContentSizeChange}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
placeholderTextColor="#9b9b9b"
scrollEnabled={capped}
style={[styles.input, { height }]}
accessibilityLabel={placeholder}
/>
<Text style={[styles.counter, remaining <= 20 && styles.counterHot]}>
{remaining}
</Text>
</View>
);
}
const styles = StyleSheet.create({
field: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#d8d8d8',
borderRadius: 16,
backgroundColor: '#fff',
overflow: 'hidden', // keep scrolled text out of the rounded corners
paddingBottom: 26, // room under the text for the counter
},
input: {
fontSize: 16,
lineHeight: 22,
color: '#111',
paddingTop: 12,
paddingBottom: 12,
paddingHorizontal: 16,
textAlignVertical: 'top',
includeFontPadding: false,
},
counter: {
position: 'absolute',
right: 12,
bottom: 8,
fontSize: 12,
color: '#8a8a8a',
fontVariant: ['tabular-nums'],
},
counterHot: { color: '#c1121f' },
});
Three details in there are load-bearing. scrollEnabled is an iOS
prop, and it stays false until the box hits its ceiling, because
an input that scrolls internally while it is still growing feels haunted. On
Android the EditText scrolls on its own once the height is
capped, so passing the same flag is harmless. overflow: 'hidden'
earns its line for the same reason: the moment the text scrolls inside a
16-point radius, the lines render out through the corners without it. And
there is no maxLength on the input, which is deliberate.
Counting characters the way a human counts them
maxLength is enforced natively and counts UTF-16 code units. So
does value.length in JavaScript. Both of them think one emoji is
two characters, which is why a 280-character limit silently becomes 140 for
someone writing in emoji.
[...value].length spreads by code point, so one ordinary emoji
counts as one, which is the common case and cheap. If you need true
grapheme counting, where a flag or a family emoji counts as one, that is
Intl.Segmenter, and you should check it exists on your runtime
before you depend on it rather than assume Hermes ships it in your build.
That gap is why the component above ships no maxLength. A native
cap counts code units while the counter counts code points, so put two emoji
in the field and the input stops accepting text while the counter still says
there is room, which is the two of them disagreeing in front of a user.
maxLength also hard-truncates: a paste of 400 characters silently
loses 120. Let the counter go negative in red and gate the submit button on
remaining < 0 instead. One rule, enforced in one place, and
nothing anyone typed disappears.
How do you animate the growth so it does not jump?
Move the height into a Reanimated shared value and write it with
withTiming. Roughly 120 milliseconds is the sweet spot: long
enough to read as motion, short enough that the caret never lags behind the
character you just typed.
import { useRef, useState } from 'react';
import {
NativeSyntheticEvent,
TextInput,
TextInputContentSizeChangeEventData,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
// MIN_HEIGHT, MAX_HEIGHT, Props and styles are the ones from the component above
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
export function AnimatedTextArea({ value, onChangeText }: Props) {
const height = useSharedValue(MIN_HEIGHT);
const target = useRef(MIN_HEIGHT);
const [capped, setCapped] = useState(false);
const boxStyle = useAnimatedStyle(() => ({ height: height.value }));
const handleContentSizeChange = (
e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) => {
const measured = Math.round(e.nativeEvent.contentSize.height);
const next = Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, measured));
// the same bail-out as the state version, and just as mandatory:
// without it every event restarts the timing from mid-flight
if (next !== target.current) {
target.current = next;
height.value = withTiming(next, { duration: 120 });
}
setCapped(measured >= MAX_HEIGHT);
};
return (
<AnimatedTextInput
multiline
value={value}
onChangeText={onChangeText}
onContentSizeChange={handleContentSizeChange}
scrollEnabled={capped}
style={[styles.input, boxStyle]}
/>
);
}
The honest trade-offs. Use withTiming, not
withSpring: a spring overshoots, and an overshooting textarea
clips its own last line on the way back. And height is a layout
property, so every frame of that animation runs layout on the subtree. For
one composer at the bottom of a screen that is free. Inside a list of fifty
editable rows, measure it before you ship it.
This is the same shape of problem as every other input we build: the state is
trivial, the feel is all in what happens between two states. The
shimmer input and the
OTP code input in our
catalog are both a plain TextInput
underneath, with the entire personality in the layer above it.
How do you stop the keyboard covering the textarea?
A textarea is usually at the bottom of a form, which puts it exactly where the
keyboard lands. The built-in answer is
KeyboardAvoidingView and it is fine for simple screens, with one
caveat: behavior="height" on Android usually fights the system
resize rather than helping it, so pass undefined there.
import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { useHeaderHeight } from '@react-navigation/elements';
export function ComposeScreen() {
const headerHeight = useHeaderHeight();
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerHeight}
>
<ScrollView
contentContainerStyle={{ padding: 16, flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
{/* form fields */}
</ScrollView>
</KeyboardAvoidingView>
);
}
Two things to check before you blame the component.
keyboardVerticalOffset has to account for everything sitting
above the avoiding view, which on a stack screen is the header, and a wrong
number there is the most common reason the input still ends up half covered.
useHeaderHeight() gives you the real one. And
keyboardShouldPersistTaps="handled" is what lets someone tap Send
while the keyboard is open, instead of the first tap only dismissing the
keyboard. What you do not need is
softwareKeyboardLayoutMode in your Expo config:
resize is already the default, and on SDK 54, where edge-to-edge
is enforced on Android, panning is not on the menu anyway.
When you want the input to track the keyboard frame by frame rather than snap after it, react-native-keyboard-controller is the package worth adding. It exposes the keyboard height as a Reanimated shared value, so a composer can ride the keyboard up in sync instead of arriving late. It is a native module, so it needs a development build, not Expo Go.
Why does scrolling inside the textarea scroll the page instead?
Because two scrollable views are stacked and the parent claims the gesture
first. Once your input caps out at MAX_HEIGHT and starts
scrolling internally, a drag over it is ambiguous, and ScrollView
wins that argument by default.
Three fixes, in the order we would try them:
- Take the input out of the scroll view. If it is a composer, pin it to the bottom of the screen outside the scrollable region, the way every messaging app does. This makes the conflict impossible instead of negotiable.
-
Lower the ceiling. A
maxHeightof about eight lines means the inner scroll almost never engages. Most people submit long before that. -
Hand the gesture over conditionally. Disable the parent
only while the input is focused and capped, so ordinary page scrolling is
untouched. Those are exactly the two flags the component already tracks,
which is what
onFocus,onBlurandonCappedChangeare for: the screen holds them, so theScrollViewcan read them.
import { useState } from 'react';
import { ScrollView } from 'react-native';
import { TextArea } from './TextArea';
export function ComposeForm() {
const [value, setValue] = useState('');
const [focused, setFocused] = useState(false);
const [capped, setCapped] = useState(false);
// scrollEnabled is a ScrollView prop on both platforms, unlike the
// iOS-only scrollEnabled on TextInput
return (
<ScrollView
keyboardShouldPersistTaps="handled"
scrollEnabled={!(focused && capped)}
>
{/* the rest of the form */}
<TextArea
value={value}
onChangeText={setValue}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onCappedChange={setCapped}
/>
</ScrollView>
);
}
If the textarea sits under a FlatList, put it in a sibling view
below the list rather than in ListFooterComponent. A focused
input inside a virtualized list can be unmounted by recycling while the
keyboard is open, and the bug that produces looks like a keyboard that
dismisses itself for no reason.
Should you install a textarea library?
Check what you are actually buying first. The component libraries that ship a
Textarea, including react-native-reusables, gluestack-ui and Tamagui, are
styling layers over the same TextInput multiline. They give you
tokens, variants and a consistent look with the rest of their kit. That is
real value if you are already in that ecosystem. What they generally do not
give you is auto-height, a counter, or the keyboard and scroll behavior
above, so those problems stay yours either way.
The standalone auto-grow packages are a different trade. You are taking on a dependency, and an upgrade path you do not control, for roughly a hundred lines you can read in one sitting. Check the last commit date and whether the package has been through the New Architecture before you add one.
The other text-entry controls that need the same care are written up separately, free and copy-paste: number input and OTP input.
What is the React Native equivalent of a textarea?
React Native has no TextArea component. The equivalent is
TextInput with the multiline prop, which renders a
UITextView on iOS and a multi-line EditText on
Android. Component libraries that export a Textarea are wrappers
around that same prop, so the auto-height, counter and keyboard work is yours
either way.
How do I make a multiline TextInput grow with its content?
Handle onContentSizeChange, read
e.nativeEvent.contentSize.height, round it, clamp it between a
minimum and maximum height, and store the result in state that you apply as
the input's height style. Skip the state update when the clamped
value has not changed, otherwise the measurement and the height can feed each
other in a loop on Android. Set scrollEnabled to true only once
the maximum is reached, so the input scrolls internally instead of growing
past its ceiling.
Why is my multiline TextInput text vertically centered on Android?
Android centers text vertically when the input is taller than its content. Add
textAlignVertical: 'top' to the input's style, which is an
Android-only text style property, or use the newer verticalAlign:
'top' alias. While you are there, add includeFontPadding:
false to remove the extra font-metrics space Android reserves above and
below the glyphs, which is what makes the same input sit a few pixels lower
than on iOS.
Why does my textarea have different padding on iOS and Android?
It is not the horizontal padding, despite the widely repeated advice to
subtract about 5 points on iOS for line-fragment padding. React Native's
multiline text view already sets that padding to zero itself, so the same
paddingHorizontal value renders the same on both platforms, and
subtracting is what creates a mismatch. The difference you are seeing is
vertical: Android reserves extra font-metrics space above and below the
glyphs, and centers the text when the box is taller than its content. Add
includeFontPadding: false and
textAlignVertical: 'top' to the input style and the two platforms
line up.
Does maxLength count emoji correctly?
Not the way a person would. maxLength is enforced natively in
UTF-16 code units, so most emoji count as two and some family or flag
sequences count as many more. value.length in JavaScript agrees
with it. [...value].length counts code points, which is closer to
what people expect, and Intl.Segmenter counts real grapheme
clusters if your runtime provides it. Pick one and use it for both the counter
and the submit check, or the two will disagree in front of a user.
The short version
A React Native textarea is TextInput with multiline,
a clamped height driven by onContentSizeChange, two
Android-specific style properties, a counter that counts code points, and a
decision about who owns the scroll gesture. None of it is hard. It is just
scattered across issue threads instead of sitting on one page.
Build it once, keep it in your own components folder, and every form you write after this gets a textarea that behaves the same on both platforms.
React NativeExpoTextInputFormsReanimated