horizontal lays the children out in a row and scrolls sideways; pagingEnabled stops only at multiples of the view's width, which makes a carousel. Each page below is the window width from useWindowDimensions(), a negative margin cancels the screen padding, and onMomentumScrollEnd, which fires once the fling settles, turns the offset into a page number (slide is a style with flex: 1 and white text):
<ScrollView horizontal pagingEnabled showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -16, flexGrow: 0 }}
onMomentumScrollEnd={(e) =>
setPage(Math.round(e.nativeEvent.contentOffset.x / width))}>
{books.map((b, i) => (
<View key={b.id} style={{ width, height: 300, padding: 16 }}>
<Text style={[ui.h1, slide, { backgroundColor: COLORS[i] }]}>{b.title}</Text>
</View>
))}
</ScrollView>
<Text style={ui.label}>Page {page + 1} of {books.length}</Text>Three swipes sent with adb shell input swipe 900 600 150 600 400 moved the label to "Page 2 of 6", "Page 3 of 6" and "Page 4 of 6": one page per fling, however far the finger travels.
For cards narrower than the screen, use snapToInterval (card width plus gap) with decelerationRate="fast"; it overrides pagingEnabled. stickyHeaderIndices does not work with horizontal. A horizontal ScrollView inside a vertical one just works; two in the same direction need nestedScrollEnabled on Android.