Skip to main content

Scroll Views

Making content scroll on Web vs. Native is a bit different.

If you come from a Web background, you're used to the window automatically scrolling when content grows beyond the height of the page.

On Native, you have to implement a ScrollView:

tsx
import { ScrollView } from 'react-native'
const Scrollable = () => (
<ScrollView>
<ContentThatShouldScroll />
</ScrollView>
)
tsx
import { ScrollView } from 'react-native'
const Scrollable = () => (
<ScrollView>
<ContentThatShouldScroll />
</ScrollView>
)

And there you go! Right?

...but if that's all, why is there a whole page dedicated to this?

ScrollView has a few quirks on Web in my experience. This page aims to clarify these quirks and show you how to handle them elegantly.

On Web, we occasionally need some tricks for a ScrollView to work as expected, such as adding height: 100% to the HTML body. The Solito starter handles all that for you with @expo/next-adapter, so this isn't something you have to worry about.

However, while the boilerplate is done for you, there are some considerations you need to keep in mind to make ScrollView behave the way you want on Web.

We need to solve for a few small issues:

  1. Parent height: ScrollView's parent needs a fixed height
  2. Window scrolling: ScrollView replaces window scrolling on Web. This means that a mobile browser's URL bar will not collapse as you scroll.

ScrollView's parent​

In order to scroll, ScrollView uses the overflow CSS property on Web. However, this means that it needs to be taller than its parent to be "overflowing".

If you want a ScrollView to handle the scroll, wrap it with a View that has flex: 1:

tsx
import { View, ScrollView } from 'react-native'
export default function ArtistPage() {
return (
<View style={{ flexGrow: 1, flexBasis: 0 }}>
<ScrollView>
<ContentThatShouldScroll />
</ScrollView>
</View>
)
}
tsx
import { View, ScrollView } from 'react-native'
export default function ArtistPage() {
return (
<View style={{ flexGrow: 1, flexBasis: 0 }}>
<ScrollView>
<ContentThatShouldScroll />
</ScrollView>
</View>
)
}

By adding flexGrow: 1, flexBasis: 0, the parent view is fixed to the height of the screen (or its parent).

Absolute Fill​

There may be some situations where flexGrow: 1, flexBasis: 0 on the parent doesn't work perfectly. In my experience, a fool-proof way to solve it is to absolutely position the parent view:

tsx
import { View, ScrollView, StyleSheet } from 'react-native'
export default function ArtistPage() {
return (
<View style={StyleSheet.absoluteFill}>
<ScrollView>
<ContentThatShouldScroll />
</ScrollView>
</View>
)
}
tsx
import { View, ScrollView, StyleSheet } from 'react-native'
export default function ArtistPage() {
return (
<View style={StyleSheet.absoluteFill}>
<ScrollView>
<ContentThatShouldScroll />
</ScrollView>
</View>
)
}

This ensures the parent view is the size of its parent, and the ScrollView will scroll within it. However, I consider it an escape hatch.

When not to use Absolute Fill​

StyleSheet.absoluteFill does not play nicely on Native with screens that have a fixed footer at the bottom, and a keyboard that needs to open. For example, a chat screen with an input at the bottom that sits above the keyboard won't respond correctly to StyleSheet.absoluteFill.

If you're building a screen that has an input or some other element that should be fixed to the bottom of the screen, you should stick to flex: 1 for the parent view.

tsx
import { View, ScrollView, StyleSheet } from 'react-native'
export default function ArtistPage() {
return (
<View style={{ flexGrow: 1, flexBasis: 0 }}>
{/* Make the messages span full height */}
<View style={{ flexGrow: 1, flexBasis: 0 }}>
<ScrollView>
<MessagesList />
</ScrollView>
</View>
<Composer />
</View>
)
}
tsx
import { View, ScrollView, StyleSheet } from 'react-native'
export default function ArtistPage() {
return (
<View style={{ flexGrow: 1, flexBasis: 0 }}>
{/* Make the messages span full height */}
<View style={{ flexGrow: 1, flexBasis: 0 }}>
<ScrollView>
<MessagesList />
</ScrollView>
</View>
<Composer />
</View>
)
}

Alternatively, you could apply the absolute position on Web only:

tsx
import { View, ScrollView, StyleSheet, Platform } from 'react-native'
export default function ArtistPage() {
return (
<View
style={Platform.select({
web: StyleSheet.absoluteFill,
default: { flexGrow: 1, flexBasis: 0 },
})}
>
{/* Make the messages span full height */}
<View style={{ flexGrow: 1, flexBasis: 0 }}>
<ScrollView>
<MessagesList />
</ScrollView>
</View>
<Composer />
</View>
)
}
tsx
import { View, ScrollView, StyleSheet, Platform } from 'react-native'
export default function ArtistPage() {
return (
<View
style={Platform.select({
web: StyleSheet.absoluteFill,
default: { flexGrow: 1, flexBasis: 0 },
})}
>
{/* Make the messages span full height */}
<View style={{ flexGrow: 1, flexBasis: 0 }}>
<ScrollView>
<MessagesList />
</ScrollView>
</View>
<Composer />
</View>
)
}

You'll have to play with it in your own app a little.

Window scrolling​

The solutions above are fine for many cases. However, they all disable window scrolling. On a desktop browser, this doesn't really matter.

But on a mobile browser, opting out of window scrolling affects the UI.

To start, let's compare what it looks like with vs without window scrolling.

With window scrollingWithout window scrolling

Watch what happens to the URL bar at the bottom. Notice that, only in the first video, it collapses. This is because it's using window scrolling.

But wait, I thought ScrollView got rid of window scrolling. How do we achieve it anyway?

I use a component called ScreenScrollView. It lives at the root of basically all scrollable screens.

I use it like this:

tsx
export default function ArtistPage() {
return (
<ScreenScrollView useWindowScrolling={true}>
<ArtistContent />
</ScreenScrollView>
)
}
tsx
export default function ArtistPage() {
return (
<ScreenScrollView useWindowScrolling={true}>
<ArtistContent />
</ScreenScrollView>
)
}

Notice that there is a useWindowScrolling prop. This determines if we want to opt-in to window scrolling.

The component code looks something like this:

tsx
import { View, ScrollView, Platform } from 'react-native'
type Props = React.ComponentProps<typeof ScrollView> & {
useWindowScrolling?: boolean
}
export function ScreenScrollView({
useWindowScrolling = true, // defaults to true
...props
}: Props) {
const Component = Platform.select({
web: useWindowScrolling ? (View as typeof ScrollView) : ScrollView,
default: ScrollView,
})
return <Component {...props} />
}
tsx
import { View, ScrollView, Platform } from 'react-native'
type Props = React.ComponentProps<typeof ScrollView> & {
useWindowScrolling?: boolean
}
export function ScreenScrollView({
useWindowScrolling = true, // defaults to true
...props
}: Props) {
const Component = Platform.select({
web: useWindowScrolling ? (View as typeof ScrollView) : ScrollView,
default: ScrollView,
})
return <Component {...props} />
}
tip

Components like ScreenScrollView are a good pattern to follow in general. Create your own low-level UI primitives that you use often so that you can update all of your screens in one place.

For screens that should use window scrolling, we use a regular old View instead of ScrollView, but only on Web.

If you're using useWindowScrolling={true}, don't wrap ScreenScrollView in a View with flex: 1.

FlatLists​

If you're using FlatList, you probably can't use window scrolling, so you should wrap it with a View with flexGrow: 1, flexBasis: 0. And if that doesn't work, try the absolute fill escape hatch.

It's worth noting that FlatList has zero virtualization on Web, so it doesn't really offer any performance benefit to use it. But it does let you share code. The video shown above of the BeatGig search uses FlatList.