Why do React Native SectionList section headers stick on iOS but scroll away on Android, and how do you make them consistent?
answer
- platform standard default
- stickySectionHeadersEnabled
- true on iOS, false on Android
- built on ScrollView sticky indices
- next header pushes the previous
basics
~20 sstickySectionHeadersEnabled defaults to true on iOS and false on Android, following each platform's convention. Set it explicitly on the React Native SectionList to get the same behaviour on both, and give headers an opaque background.
solid answer
~40 s`SectionList`'s `stickySectionHeadersEnabled` defaults to `Platform.OS === 'ios'`: headers pin to the top on iOS because that is the platform standard, and scroll away with the content on Android. Pass `stickySectionHeadersEnabled` explicitly — `true` for an A-Z music library on both platforms, or `false` if the design wants them to scroll. Under the hood the list computes the position of every section header in its flattened cells (shifted by one when there is a `ListHeaderComponent`, which itself never sticks) and hands them to the scroll view as `stickyHeaderIndices`, so the next section's header pushes the previous one off. Sticky headers need an opaque background, and on Android enabling them also turns off subview clipping for the content container. Because pinned headers cover the top of the viewport, scroll-to targets may need a `viewOffset`.
code
tsx · 27 linesimport {SectionList, StyleSheet, Text} from 'react-native';
type Artist = {id: string; name: string};
type LetterSection = {key: string; title: string; data: Artist[]};
export function LibraryByArtist({sections}: {sections: LetterSection[]}) {
return (
<SectionList
sections={sections}
keyExtractor={artist => artist.id}
// Same behaviour on both platforms instead of the per-platform default.
stickySectionHeadersEnabled
ListHeaderComponent={<Text style={styles.title}>Artists</Text>} // never sticks
renderSectionHeader={({section}) => (
<Text style={styles.header}>{section.title}</Text>
)}
renderItem={({item}) => <Text style={styles.row}>{item.name}</Text>}
/>
);
}
const styles = StyleSheet.create({
title: {fontSize: 28, fontWeight: '700', padding: 12},
// Opaque, so rows do not show through the pinned header.
header: {backgroundColor: '#ffffff', paddingHorizontal: 12, paddingVertical: 4, fontWeight: '700'},
row: {paddingHorizontal: 12, paddingVertical: 10},
});go deeper
Remember the default: section headers stick on iOS and scroll away on Android unless you set stickySectionHeadersEnabled yourself.
Explain how the list turns section header positions into the scroll view's stickyHeaderIndices, why the list header does not stick, and why headers need a solid background.
Make pinned headers work with the rest of the screen: consistent behaviour across platforms, programmatic scrolls that do not hide rows under a header, and header animations that do not fight the sticky transform.
Decide per product surface whether grouped lists pin their headers, and encode that choice in a shared list component so platforms do not silently diverge.
## The symptom An A-Z music library built with `SectionList` looks right on an iPhone: while you scroll through the artists under **M**, the **M** header stays pinned at the top until **N** pushes it off. On an Android phone, the same code scrolls the headers away with the rows. ## The default is per platform The prop that controls this is **`stickySectionHeadersEnabled`**. Its default is not a single value: | Platform | Default | Reason given in the docs | |---|---|---| | iOS | `true` | sticky headers are the platform standard there | | Android | `false` | not the platform standard | In the implementation, the default is literally `stickySectionHeadersEnabled ?? Platform.OS === 'ios'`. The docs for `renderSectionHeader` repeat it: headers "stick to the top of the `ScrollView` by default on iOS". ## Making it consistent Set the prop explicitly: - `stickySectionHeadersEnabled` (or `={true}`) — pinned headers on both platforms, the usual choice for an alphabetical index; - `stickySectionHeadersEnabled={false}` — headers scroll away on both platforms, for a feed-like grouped list where pinned headers waste space. Treat it as a design decision rather than leaving each platform to its default, and test both. ## How sticky headers work underneath `SectionList` does not implement pinning itself; it reuses the scroll view's sticky-header mechanism: 1. `VirtualizedSectionList` flattens the sections into one sequence of cells: for each section, a header slot, its items, and a footer slot. 2. When `stickySectionHeadersEnabled` is on, it records the position of each **section header** in that sequence, adding one when a `ListHeaderComponent` occupies the first cell. 3. It passes those positions to the underlying list as `stickyHeaderIndices`, which ends up on the `ScrollView`. 4. The scroll view wraps each of those cells in a sticky header wrapper; the next sticky header pushes the previous one out. Consequences worth stating: - **`ListHeaderComponent` does not stick**; only section headers do. - **Section footers never stick.** - Headers are drawn over the rows passing beneath them, so give them an **opaque background**. - On Android, the `ScrollView` disables `removeClippedSubviews` on its content container whenever sticky headers are present, because clipping causes issues with them. ## Interactions to watch - **Scrolling to a row.** A pinned header covers the top of the viewport. `scrollToLocation` compensates for the header height when sticky headers are on and the target is an item rather than the header, and `viewOffset` exists for any extra overlap such as a translucent navigation bar. - **Very tall headers.** A header taller than a few rows takes a large share of the screen while pinned; keep sticky headers compact. - **Custom header animations.** The sticky mechanism applies its own transform to the header wrapper; animating the header's position yourself can fight it. ## A quick decision guide 1. Is the list an index the user navigates by group (contacts, artists, settings sections)? Enable sticky headers on both platforms. 2. Is it a long feed where groups are dates or categories the user skims past? Consider disabling them on both. 3. Either way, set the prop explicitly and give headers a solid background. ## Common mistakes - Filing "headers do not stick on Android" as a bug instead of reading the default. - Transparent headers that let rows show through. - Expecting the list header or section footers to stick. - Forgetting that pinned headers can hide the first row after a programmatic scroll.
- Does ListHeaderComponent stick when stickySectionHeadersEnabled is on?No. The list only records the positions of section headers, offset by one to skip the list header cell. The list header scrolls away with the content, and only section headers pin, each pushed off by the next.
- Why do sticky headers need an opaque background?A pinned header stays in place while rows scroll underneath it. With a transparent background, the rows show through the header text, which looks broken. Give the header view a solid background colour that matches the screen.
saying these in an interview costs you the question
- stickySectionHeadersEnabled defaults to true on both platforms.
- Headers not sticking on Android is a React Native bug to work around.
- ListHeaderComponent sticks along with the section headers.
- Section footers stick to the bottom when sticky headers are enabled.
- SectionList draws sticky headers with a separate native table view.