ScrollView

ScrollView scrolls bounded content: style is the window, contentContainerStyle is the content, and long lists belong in FlatList.

Syntax<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>...</ScrollView>

A ScrollView makes content that is taller than the screen scrollable. It renders every child at once and keeps them all mounted, so it fits a screen of bounded, known content: a form, an article, a settings page.

style is not contentContainerStyle

There are two views in play. The outer one is the scrolling window, styled by style; give it flex: 1 and a background colour. The inner one holds the children, styled by contentContainerStyle; padding, gap, alignItems and justifyContent belong there. Padding on style crops the window instead of insetting the content, and justifyContent on style does nothing at all.

Do not put flex: 1 in contentContainerStyle. It pins the content to the height of the window and scrolling stops. flexGrow: 1 is what is usually meant: short content fills the screen, long content still scrolls.

Horizontal

horizontal is shorthand for horizontal={true} and lays the content container out in a row. Alignment and padding still go in contentContainerStyle.

Where it is the wrong tool

A long or open-ended list belongs in a FlatList. ScrollView builds JavaScript components and native views for every item immediately, including the ones off screen, which costs memory and startup time. FlatList mounts items as they approach the viewport and drops them once they are far away.

Example

Example · tsx
import { ScrollView, StyleSheet, Text, View } from "react-native";

const SECTIONS = [
  "Account",
  "Notifications",
  "Privacy",
  "Storage",
  "Appearance",
  "Language",
  "About",
];

export default function SettingsScreen() {
  return (
    <ScrollView
      style={styles.scroll} // the scrolling window
      contentContainerStyle={styles.content} // the view holding the children
      showsVerticalScrollIndicator={false}
    >
      {SECTIONS.map((name) => (
        <View key={name} style={styles.row}>
          <Text style={styles.rowText}>{name}</Text>
        </View>
      ))}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  scroll: { flex: 1, backgroundColor: "#f4f4f5" },
  content: {
    // paddingVertical: 24 is shorthand for paddingTop and paddingBottom: 24.
    paddingVertical: 24,
    paddingHorizontal: 16,
    gap: 12, // gap works in React Native 0.71 and later
  },
  row: {
    padding: 16,
    borderRadius: 8,
    backgroundColor: "#ffffff",
  },
  rowText: { fontSize: 16 },
});

// On screen: a grey screen of white rows with 24pt of breathing room above
// the first and below the last, scrolling under the edges of the window. The
// padding lives in the content, so it scrolls with it; had it been on style,
// the window itself would have shrunk.

When to use it

  • Make a settings or profile screen scroll when it is taller than the phone.
  • Centre a short empty state or form, while still scrolling on a small screen.
  • Build a horizontal strip of filter chips or a carousel of cards.
  • Decide whether a screen should be a ScrollView at all, or has outgrown it.

More examples

A horizontal row of chips

On screen: a single row of rounded chips that slides sideways, with no scrollbar. Two details catch people out. The content container becomes a row, so alignItems now controls vertical alignment. And a horizontal ScrollView inside a column will stretch to fill the height unless you stop it with flexGrow: 0 or a fixed height.

Example · tsx
import { ScrollView, StyleSheet, Text } from "react-native";

const TAGS = ["All", "Unread", "Mentions", "Flagged", "Archived", "Spam"];

export default function TagBar() {
  return (
    <ScrollView
      horizontal // same as horizontal={true}
      style={styles.bar} // height of the strip lives here
      contentContainerStyle={styles.content} // row spacing lives here
      showsHorizontalScrollIndicator={false}
    >
      {TAGS.map((tag) => (
        <Text key={tag} style={styles.chip}>
          {tag}
        </Text>
      ))}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  bar: { flexGrow: 0 }, // do not let the strip eat the rest of the screen
  content: {
    paddingHorizontal: 12,
    gap: 8,
    alignItems: "center",
  },
  chip: {
    paddingVertical: 6,
    paddingHorizontal: 14,
    borderRadius: 999,
    backgroundColor: "#e4e4e7",
    fontSize: 14,
  },
});

Centring short content without breaking scrolling

On screen: the message sits in the middle of the screen, and if the text grows long enough to overflow it scrolls instead of being cut off. This is the standard pattern for an empty state or a short form inside a scroll view, and the flex versus flexGrow distinction is the whole trick.

Example · tsx
import { ScrollView, StyleSheet, Text, View } from "react-native";

type EmptyStateProps = {
  message: string;
};

export default function EmptyState({ message }: EmptyStateProps) {
  return (
    <ScrollView
      style={styles.scroll}
      // flexGrow: 1 lets the content grow to fill a tall window but still
      // exceed it when there is more. flex: 1 would clamp it to the window
      // height and scrolling would stop working.
      contentContainerStyle={styles.content}
    >
      <View style={styles.card}>
        <Text style={styles.title}>Nothing here yet</Text>
        <Text style={styles.body}>{message}</Text>
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  scroll: { flex: 1, backgroundColor: "#ffffff" },
  content: {
    flexGrow: 1,
    justifyContent: "center", // ignored if placed on style
    alignItems: "center",
    padding: 24,
  },
  card: { alignItems: "center", gap: 8, maxWidth: 320 },
  title: { fontSize: 18, fontWeight: "600" },
  body: { fontSize: 15, color: "#52525b", textAlign: "center" },
});

A sticky header inside the scroll

On screen: the "Release notes" bar scrolls with the content until it reaches the top, then stays pinned while the entries pass behind it. The header needs an opaque backgroundColor or the content shows through once it sticks. Twelve items is a reasonable ScrollView; twelve hundred is a FlatList.

Example · tsx
import { ScrollView, StyleSheet, Text, View } from "react-native";

const ENTRIES = Array.from({ length: 12 }, (_, i) => `Entry ${i + 1}`);

export default function Changelog() {
  return (
    <ScrollView
      style={styles.scroll}
      contentContainerStyle={styles.content}
      // Indices of children that dock to the top of the window as you
      // scroll past them. Not supported when horizontal is true.
      stickyHeaderIndices={[0]}
      // iOS only: let the system inset the content under the status bar.
      contentInsetAdjustmentBehavior="automatic"
    >
      <View style={styles.header}>
        <Text style={styles.headerText}>Release notes</Text>
      </View>
      {ENTRIES.map((entry) => (
        <Text key={entry} style={styles.entry}>
          {entry}
        </Text>
      ))}
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  scroll: { flex: 1 },
  content: { paddingBottom: 32 },
  header: {
    paddingVertical: 12,
    paddingHorizontal: 16,
    backgroundColor: "#ffffff",
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: "#d4d4d8",
  },
  headerText: { fontSize: 16, fontWeight: "600" },
  entryText: { fontSize: 15 },
  entry: { paddingVertical: 10, paddingHorizontal: 16, fontSize: 15 },
});

Discussion

  • Be the first to comment on this lesson.