Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 9 min read

React Native Carousel: The Options That Still Work (2026)

Search “react native carousel” and the top results still point at a library that stopped being maintained years ago. Meanwhile most of the carousels people actually need — a row of cards that snaps, a full-bleed image pager, three onboarding slides — do not need a carousel library at all. This is the decision, then the working code: a snapping FlatList, dots that track the finger instead of lagging behind it, autoplay that stops when the screen is not visible, and the Android bug that makes cards go blank mid-swipe.

Pick by what the carousel has to do

ApproachDependenciesInfinite loopBest for
FlatList + snapToIntervalNoneNo (hacks glitch)Cards with peek, product rows, anything already list-shaped
react-native-reanimated-carouselreanimated, gesture-handlerYes, properlyHero carousels, parallax and stack effects, autoplay
react-native-pager-viewNative module (dev build)NoFull-screen paging, swipeable tabs, gesture feel identical to the OS
ScrollView + pagingEnabledNoneNoThree 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.

Frequently Asked Questions

Is react-native-snap-carousel still usable in 2026?

No, and you should stop reaching for it. It has been effectively unmaintained since 2021, it does not support the New Architecture, and the peer-dependency warnings you get on install are a real signal rather than noise. The community successor is react-native-reanimated-carousel, which is built on Reanimated and gesture-handler and is actively maintained.

How do I make a FlatList snap like a carousel?

Set horizontal, then snapToInterval to the exact width of one card including its gap, decelerationRate to "fast", and snapToAlignment to "start". The single most common bug is a snapToInterval that does not match the real rendered width — if your card has a margin, the interval must include it, or every swipe drifts a few pixels further out of alignment.

How do I add pagination dots to a React Native carousel?

Drive them from scroll position rather than from an onMomentumScrollEnd index. Capture contentOffset.x into a Reanimated shared value with useAnimatedScrollHandler, then interpolate each dot width and opacity against it. Index-based dots update after the swipe finishes, which reads as laggy; interpolated dots move with the finger.

Why are my carousel cards blank on Android?

Usually removeClippedSubviews, which is enabled by default on Android VirtualizedLists. It detaches off-screen views, and with a horizontal list whose items are transformed or animated it can detach cards that are actually on screen, leaving blank gaps during a fast swipe. Set removeClippedSubviews to false on horizontal carousels — the number of cards is small enough that you lose nothing.

Should a carousel or a grid be used on a mobile home screen?

A carousel hides everything after the first card, so it only earns its place when the items are genuinely equivalent and browsable — featured content, onboarding, product photos. For anything the user is trying to find rather than browse, a vertical list or grid wins, because it can be scanned rather than paged through.

→

React Native FlatList: The Props That Matter

The list fundamentals a horizontal carousel is built on.

Read guide →
→

React Native Animations with Reanimated

Shared values and interpolation — the machinery behind scroll-driven dots.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.