How to build an animated carousel in React Native

By the LumioUI team · Updated

A carousel is a row of pages you swipe through one at a time, with dots that show where you are. In React Native, put the pages in a horizontal ScrollView with pagingEnabled, read the scroll offset into a Reanimated shared value, and derive each page's scale and each dot's width from it on the UI thread. The LumioUI Carousel stretches its dots as you swipe and fades side pages back so each new one arrives.

Recording of the LumioUI Carousel component (Pro).

How an animated carousel works

  1. Paging. A horizontal ScrollView (or FlatList for long lists) with pagingEnabled snaps to whole pages. Each page is the width of the carousel.
  2. One shared value. useAnimatedScrollHandler writes the offset to a shared value without going through React state, so nothing re-renders while you swipe and the motion stays at 60fps.
  3. Interpolate per page. offset / width is the page position as a number (1.5 = halfway between pages 1 and 2). Each page and dot maps its distance from that number to scale, opacity or width.
  4. Report the page on settle. Update React state (the current index) only when a swipe ends, not on every frame.
  5. Accessibility. Give the carousel an adjustable role, or label each page "Photo 2 of 5", so screen reader users know there is more to swipe.
TSX PhotoCarousel.tsx
import * as React from "react";
import { Image, StyleSheet, View, useWindowDimensions } from "react-native";
import Animated, {
  Extrapolation, interpolate, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue,
  type SharedValue,
} from "react-native-reanimated";

export function PhotoCarousel({ photos }: { photos: string[] }) {
  const { width } = useWindowDimensions();
  const x = useSharedValue(0); // scroll offset, read on the UI thread
  const onScroll = useAnimatedScrollHandler((e) => { x.value = e.contentOffset.x; });

  return (
    <View>
      <Animated.ScrollView horizontal pagingEnabled showsHorizontalScrollIndicator={false}
        onScroll={onScroll} scrollEventThrottle={16}>
        {photos.map((uri, i) => <Page key={uri} uri={uri} i={i} x={x} width={width} />)}
      </Animated.ScrollView>
      <View style={styles.dots}>
        {photos.map((uri, i) => <Dot key={uri} i={i} x={x} width={width} />)}
      </View>
    </View>
  );
}

function Page({ uri, i, x, width }: { uri: string; i: number; x: SharedValue<number>; width: number }) {
  // Pages beside the current one recede: a little smaller and dimmer.
  const style = useAnimatedStyle(() => {
    const p = interpolate(x.value / width, [i - 1, i, i + 1], [0, 1, 0], Extrapolation.CLAMP);
    return { opacity: 0.6 + 0.4 * p, transform: [{ scale: 0.92 + 0.08 * p }] };
  });
  return (
    <Animated.View style={[{ width, padding: 16 }, style]}>
      <Image source={{ uri }} style={styles.photo} accessibilityIgnoresInvertColors />
    </Animated.View>
  );
}

function Dot({ i, x, width }: { i: number; x: SharedValue<number>; width: number }) {
  // The active dot stretches into a pill, following the finger.
  const style = useAnimatedStyle(() => {
    const p = interpolate(x.value / width, [i - 1, i, i + 1], [0, 1, 0], Extrapolation.CLAMP);
    return { width: 6 + 12 * p, opacity: 0.35 + 0.65 * p };
  });
  return <Animated.View style={[styles.dot, style]} />;
}

const styles = StyleSheet.create({
  photo: { width: "100%", aspectRatio: 4 / 5, borderRadius: 20 },
  dots: { flexDirection: "row", justifyContent: "center", gap: 6, marginTop: 4 },
  dot: { height: 6, borderRadius: 3, backgroundColor: "#111" },
});

For long lists, swap the ScrollView for Animated.FlatList so only nearby pages stay mounted.

The quick way: LumioUI Carousel

The Carousel adds stretching page dots, side pages that recede, pinch and double-tap zoom, a glass pager for photos, and only keeps the pages near the visible one mounted. It's part of LumioUI Pro.

TSX ListingPhotos.tsx
import { Image } from "react-native";
import { Carousel } from "@/components/ui/carousel/carousel";

export function ListingPhotos({ photos }: { photos: string[] }) {
  return (
    <Carousel
      data={photos}
      renderItem={(uri) => (
        <Image source={{ uri }} className="aspect-[4/5] w-full rounded-3xl" />
      )}
      onIndexChange={(i) => console.log("page", i)}
      pinchToZoom
    />
  );
}
  • Pass data and renderItem, or plain children as pages.
  • index and onIndexChange make it controlled. showDots (default on) and glass style the pager.
  • pinchToZoom and maxZoom turn it into a photo viewer. renderWindow sets how many pages stay mounted on each side.
  • For product pages with thumbnails see Product Gallery; for first-run screens, Onboarding Pager.

Add it to your app

LumioUI's CLI copies the component's source into your project, so it's yours to read and change. Run init once per project:

npx @lumioui/cli init
npx @lumioui/cli add pro/carousel

It works in Expo (SDK 54+) and bare React Native, with Reanimated 4 and NativeWind. See the installation guide and the full Carousel API.Carousel is part of LumioUI Pro: Pro installs need your licence key (CLI guide, pricing).

More React Native guides