Pick by what the carousel has to do
| Approach | Dependencies | Infinite loop | Best for |
|---|---|---|---|
FlatList + snapToInterval | None | No (hacks glitch) | Cards with peek, product rows, anything already list-shaped |
react-native-reanimated-carousel | reanimated, gesture-handler | Yes, properly | Hero carousels, parallax and stack effects, autoplay |
react-native-pager-view | Native module (dev build) | No | Full-screen paging, swipeable tabs, gesture feel identical to the OS |
ScrollView + pagingEnabled | None | No | Three onboarding slides and nothing more |
The honest default: if the cards are narrower than the screen and there is no infinite loop, use a FlatList. You get virtualization, a keyExtractor you already understand, and zero new dependencies.
Reach for react-native-reanimated-carousel when you specifically need looping, autoplay, or one of the layered effects (parallax, stack, tinder). Reach for react-native-pager-view when the page fills the screen and the gesture needs to feel native — it is the platform pager, so it does.
The snapping FlatList, done correctly
The whole trick is that one number — the snap interval — has to be the exact rendered width of a card plus its gap. Derive it, never hardcode it:
import { Dimensions, FlatList, Image, StyleSheet, Text, View } from 'react-native';
const { width: SCREEN } = Dimensions.get('window');
const GAP = 12;
const SIDE = 20; // page padding
const CARD = SCREEN - SIDE * 2 - GAP * 2; // leaves a peek of the next card
const INTERVAL = CARD + GAP; // <- the number everything depends on
export default function FeaturedRow({ items, onPress }) {
return (
<FlatList
data={items}
horizontal
keyExtractor={(item) => item.id}
showsHorizontalScrollIndicator={false}
// Snapping
snapToInterval={INTERVAL}
snapToAlignment="start"
decelerationRate="fast"
// Peek: first card starts at SIDE, so the previous card peeks in too
contentContainerStyle={{ paddingHorizontal: SIDE }}
ItemSeparatorComponent={() => <View style={{ width: GAP }} />}
// Lets scrollToIndex work without measuring
getItemLayout={(_, index) => ({
length: INTERVAL,
offset: INTERVAL * index,
index,
})}
// Android detaches on-screen cards during fast swipes without this
removeClippedSubviews={false}
renderItem={({ item }) => (
<View style={[styles.card, { width: CARD }]}>
<Image source={{ uri: item.image }} style={styles.image} />
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
card: { borderRadius: 16, overflow: 'hidden', backgroundColor: '#242424' },
image: { width: '100%', aspectRatio: 16 / 9 },
title: { color: '#fff', fontSize: 15, fontWeight: '600', padding: 12 },
});Two things people get wrong here. A margin on the card breaks the interval unless the interval includes it — which is why the gap lives in an ItemSeparatorComponent above, where it is a single value used by both the layout and the math. And decelerationRate="fast"is not cosmetic: with the default rate the list coasts past the snap point and yanks back, which is the exact feeling users describe as “the carousel is fighting me.”
Dots that track the finger
The version most apps ship uses onMomentumScrollEnd to set an index, so the dots only update once the swipe has fully stopped. Interpolating against the live scroll offset costs about the same code and feels completely different:
import Animated, {
Extrapolation,
interpolate,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated';
const AnimatedList = Animated.createAnimatedComponent(FlatList);
function Dot({ index, scrollX }) {
const style = useAnimatedStyle(() => {
const d = [(index - 1) * INTERVAL, index * INTERVAL, (index + 1) * INTERVAL];
return {
width: interpolate(scrollX.value, d, [6, 20, 6], Extrapolation.CLAMP),
opacity: interpolate(scrollX.value, d, [0.35, 1, 0.35], Extrapolation.CLAMP),
};
});
return <Animated.View style={[styles.dot, style]} />;
}
export function Carousel({ items }) {
const scrollX = useSharedValue(0);
// Runs on the UI thread — no bridge hop, no dropped frames
const onScroll = useAnimatedScrollHandler((e) => {
scrollX.value = e.contentOffset.x;
});
return (
<>
<AnimatedList
data={items}
horizontal
onScroll={onScroll}
scrollEventThrottle={16}
snapToInterval={INTERVAL}
decelerationRate="fast"
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Card item={item} />}
/>
<View style={styles.dots}>
{items.map((item, i) => <Dot key={item.id} index={i} scrollX={scrollX} />)}
</View>
</>
);
}useAnimatedScrollHandler is what makes this cheap — the handler runs on the UI thread, so the dots keep moving even while JavaScript is busy rendering the next card. A plain onScroll with setState does the opposite: sixty state updates a second, each one re-rendering the whole carousel.
Autoplay without the leak
Autoplay is where carousels quietly become a battery problem. An interval started in useEffect keeps firing after the user navigates away, because the screen is still mounted underneath the new one — it is not unmounted, only unfocused. Tie it to focus, not to mount:
import { useCallback, useRef } from 'react';
import { useFocusEffect } from 'expo-router';
function useAutoplay(listRef, count, enabled) {
const index = useRef(0);
useFocusEffect(
useCallback(() => {
if (!enabled || count < 2) return;
const id = setInterval(() => {
index.current = (index.current + 1) % count;
listRef.current?.scrollToIndex({ index: index.current, animated: true });
}, 4000);
// Runs on blur AND on unmount — the effect people forget
return () => clearInterval(id);
}, [listRef, count, enabled]),
);
// Stop autoplay the moment the user takes over
return { onScrollBeginDrag: () => { /* set enabled=false in caller state */ } };
}Then two rules that are accessibility requirements, not preferences. Autoplay must stop permanently once the user swipes — resuming it fights them for control of the screen. And it should respect AccessibilityInfo.isReduceMotionEnabled(), because auto-advancing content is a genuine problem for people with vestibular disorders, and Apple’s reviewers do occasionally note it.
When you do want the library
react-native-reanimated-carousel earns its place on exactly one thing a FlatList cannot fake: a true infinite loop. Every FlatList looping trick — duplicating the data three times, silently jumping the offset at the seam — glitches visibly on a slow swipe or when the user stops mid-seam.
import Carousel from 'react-native-reanimated-carousel';
<Carousel
width={SCREEN}
height={220}
data={slides}
loop
autoPlay={!reduceMotion}
autoPlayInterval={4000}
scrollAnimationDuration={500}
// 'parallax' and 'stack' are the two worth the extra render cost
mode="parallax"
modeConfig={{ parallaxScrollingScale: 0.9, parallaxScrollingOffset: 50 }}
onSnapToItem={setIndex}
renderItem={({ item }) => <Slide item={item} />}
/>It needs react-native-reanimated and react-native-gesture-handler, which means a development build rather than Expo Go if your project does not already have them. That is the real cost of the dependency, and it is the reason a plain FlatList is the right answer more often than the search results suggest.
Get the whole screen, not just the carousel
A featured carousel is one component on a home screen that also needs a header, a list, navigation, and data behind it. ShipNative builds the whole thing from a description — a real React Native app with working navigation and state, running on your phone in minutes, exported as a full Expo project you own. Then you tune the snap interval yourself, like any other code in your repo.