Skip to content
All posts

10 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.

The feedback loop

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:

  1. 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.
  2. Lower the ceiling. A maxHeight of about eight lines means the inner scroll almost never engages. Most people submit long before that.
  3. 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, onBlur and onCappedChange are for: the screen holds them, so the ScrollView can 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