Building a Smooth Image Carousel with FlatList in React Native
Responses (0)
Have you ever noticed how often image (and card) carousels appear within mobile applications? Carousels consolidate items within a single horizontally rotating widget. Users can scroll through items by dragging across right or left to preview subsequent or previous items. Displaying items this way preserves vertical screen real estate, and therefore, allows users to have quicker access to the bottom of the view without scrolling down endlessly.
React Native comes with several built-in components, such as <FlatList />
and <ScrollView />
, that can be used to quickly implement an image carousel. Unlike ScrollView />
, <FlatList />
renders child components lazily. Since <FlatList />
only stores in memory the items that are about to appear (and removes from memory the items that disappear offscreen), this results in better performance from reduced memory consumption and processing resources.
Below, I'm going to show you how to build an image carousel with React Native's <FlatList />
core component.
Users will be able to scroll through the items by dragging along the items:

Or by clicking an arrow button to move to the next or previous item:

Installation and Setup#
To get started, initialize a new React Native project using the TypeScript template:
$ npx react-native init <ProjectName> --template react-native-template-typescript
At the root of the project directory, create two new directories:
src
- Contains React components, providers, utilities, etc.types
- Contains global type definitions.
Delete the contents of the App.tsx
file, and replace it with the following:
(App.tsx
)
import React from 'react';
import {SafeAreaView, StatusBar, StyleSheet} from 'react-native';
import ImageCarousel from './src/components/ImageCarousel';
const data: ImageCarouselItem[] = [
{
id: 0,
uri: 'https://images.unsplash.com/photo-1607326957431-29d25d2b386f',
title: 'Dahlia',
}, // https://unsplash.com/photos/Jup6QMQdLnM
{
id: 1,
uri: 'https://images.unsplash.com/photo-1512238701577-f182d9ef8af7',
title: 'Sunflower',
}, // https://unsplash.com/photos/oO62CP-g1EA
{
id: 2,
uri: 'https://images.unsplash.com/photo-1627522460108-215683bdc9f6',
title: 'Zinnia',
}, // https://unsplash.com/photos/gKMmJEvcyA8
{
id: 3,
uri: 'https://images.unsplash.com/photo-1587814213271-7a6625b76c33',
title: 'Tulip',
}, // https://unsplash.com/photos/N7zBDF1r7PM
{
id: 4,
uri: 'https://images.unsplash.com/photo-1588628566587-dbd176de94b4',
title: 'Chrysanthemum',
}, // https://unsplash.com/photos/GsGZJMK0bJc
{
id: 5,
uri: 'https://images.unsplash.com/photo-1501577316686-a5cbf6c1df7e',
title: 'Hydrangea',
}, // https://unsplash.com/photos/coIBOiWBPjk
];
const App = () => (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<ImageCarousel data={data} />
</SafeAreaView>
);
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default App;
The image carousel will showcase six high-quality Unsplash images of various flowers. At a minimum, each item of the carousel must have a unique ID (id
), a URI to its corresponding image's location (uri
) and a title for labeling the image (title
).
To enforce these properties, create a definition file to globally expose the ImageCarouselItem
interface.
(types/index.d.ts
)
interface ImageCarouselItem {
id: number;
uri: string;
title: string;
}
Note: Inside of the tsconfig.json
file, set the typeRoots
compiler option to ["./types"]
.
Implementing the Image Carousel with the <FlatList />
Component#
Create a new file named ImageCarousel.tsx
under the src/components
directory:
$ touch src/components/ImageCarousel.tsx
Inside of the src/components/IamgeCarousel.tsx
file, define a new functional component named ImageCarousel
that accepts the prop data
:
(src/components/ImageCarousel.tsx
)
import React from 'react';
interface ImageCarouselProps {
data: ImageCarouselItem[];
}
const ImageCarousel: FC<ImageCarouselProps> = ({ data }) => {
return (
<View />
)
};
export default ImageCarousel;
data
contains the items that will be rendered by the <FlatList />
component.
The <FlatList />
component provides a virtualized list that is highly customizable via its many props. Coincidentally, it inherits most of these props from the <ScrollView />
component. At a minimum, the <FlatList />
component requires two props:
renderItem
- Accepts a function that determines how each item is rendered.data
- The items to be rendered.
Let's render a simple horizontal carousel using the least number of props possible.
(src/components/ImageCarousel.tsx
)
import {
Dimensions,
FlatList,
Image,
StyleSheet,
Text,
View,
} from 'react-native';
const {width} = Dimensions.get('window');
const SPACING = 5;
const ITEM_LENGTH = width * 0.8; // Item is a square. Therefore, its height and width are of the same length.
const BORDER_RADIUS = 20;
const ImageCarousel: FC<ImageCarouselProps> = ({ data }) => {
return (
<View style={styles.container}>
<FlatList
data={data}
renderItem={({item, index}) => {
return (
<View style={{width: ITEM_LENGTH}}>
<View style={styles.itemContent}>
<Image source={{uri: item.uri}} style={styles.itemImage} />
<Text style={styles.itemText} numberOfLines={1}>
{item.title}
</Text>
</View>
</View>
);
}}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item.id}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {},
itemContent: {
marginHorizontal: SPACING * 3,
alignItems: 'center',
backgroundColor: 'white',
borderRadius: BORDER_RADIUS + SPACING * 2,
},
itemText: {
fontSize: 24,
position: 'absolute',
bottom: SPACING * 2,
right: SPACING * 2,
color: 'white',
fontWeight: '600',
},
itemImage: {
width: '100%',
height: ITEM_LENGTH,
borderRadius: BORDER_RADIUS,
resizeMode: 'cover',
},
});
Each item follows the same layout: a square-shaped cover image with its title overlaid above and positioned at the lower-right corner.
Specify the horizontal
prop to tell the <FlatList />
component to arrange the items horizontally rather than vertically. Hide the horizontal scroll indicator, and set a keyExtractor
function to tell the <FlatList />
component which item property (or set of properties) it can use to track each item for caching and re-ordering purposes.
Adjacent items are spaced away from each other by 30px (for each item, 15px of left- and right-margining).
If you save these changes and run the React Native project inside of an iOS simulator, then you will see a basic image carousel:

In fact, you can scroll through these items by horizontally dragging along them.
Animating the Carousel#
Let's modify the carousel to snap items in place and translate an item upwards when it approaches the middle of the screen and downwards when it moves towards either edge of the screen.
Currently, when the carousel loads the items, the first item begins at the far left end of the screen, not the middle. When you scroll all the way to the last item in the carousel, the last item ends at the far right end of the screen, not the middle.
We're going to need two placeholder items, one at the beginning and another at the end of the carousel, to help position the first and last items in the middle of the screen when reaching either end of the carousel.
(src/components/ImageCarousel.tsx
)
const [dataWithPlaceholders, setDataWithPlaceholders] = useState<
ImageCarouselItem[]
>([]);
useEffect(() => {
setDataWithPlaceholders([{id: -1}, ...data, {id: data.length}]);
}, [data]);
<FlatList
data={dataWithPlaceholders}
// ...
/>
As we scroll through the items, the item approaching the middle of the screen should be translated upwards to put it front and center as the current item being visited.
Inside of renderItem
, define an interpolation that maps an item's x-position to its y-translation.
(src/components/ImageCarousel.tsx
)
// This constant is defined outside of the `<ImageCarousel>` component.
const CURRENT_ITEM_TRANSLATE_Y = 48;
// This line is outside of `renderItem`.
const scrollX = useRef(new Animated.Value(0)).current;
const inputRange = [
(index - 2) * ITEM_LENGTH,
(index - 1) * ITEM_LENGTH,
index * ITEM_LENGTH,
];
const translateY = scrollX.interpolate({
inputRange,
outputRange: [
CURRENT_ITEM_TRANSLATE_Y * 2,
CURRENT_ITEM_TRANSLATE_Y,
CURRENT_ITEM_TRANSLATE_Y * 2,
],
extrapolate: 'clamp',
});
An item appears to move upwards when its y-translation (CURRENT_ITEM_TRANSLATE_Y
) is smaller compared to other items' y-translations (CURRENT_ITEM_TRANSLATE_Y * 2
). This item ((index - 1) * ITEM_LENGTH
) happens to be the one that appears in the middle of the screen, not the edges. clamp
restricts the extrapolation to only within the boundaries of the specific range.
For items to snap into place at the end of a scroll action, specify these additional props on the <FlatList />
component.
(src/components/ImageCarousel.tsx
)
bounces={false}
decelerationRate={0}
renderToHardwareTextureAndroid
contentContainerStyle={styles.flatListContent}
snapToInterval={ITEM_LENGTH}
snapToAlignment="start"
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {x: scrollX}}}],
{useNativeDriver: false},
)}
scrollEventThrottle={16}
Putting it altogether...
(src/components/ImageCarousel.tsx
)
import React, {FC, useCallback, useEffect, useRef, useState} from 'react';
import {
Animated,
Dimensions,
FlatList,
Image,
StyleSheet,
Text,
View,
} from 'react-native';
const {width} = Dimensions.get('window');
const SPACING = 5;
const ITEM_LENGTH = width * 0.8; // Item is a square. Therefore, its height and width are of the same length.
const EMPTY_ITEM_LENGTH = (width - ITEM_LENGTH) / 2;
const BORDER_RADIUS = 20;
const CURRENT_ITEM_TRANSLATE_Y = 48;
interface ImageCarouselProps {
data: ImageCarouselItem[];
}
const ImageCarousel: FC<ImageCarouselProps> = ({data}) => {
const scrollX = useRef(new Animated.Value(0)).current;
const [dataWithPlaceholders, setDataWithPlaceholders] = useState<
ImageCarouselItem[]
>([]);
useEffect(() => {
setDataWithPlaceholders([{id: -1}, ...data, {id: data.length}]);
}, [data]);
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={dataWithPlaceholders}
renderItem={({item, index}) => {
if (!item.uri || !item.title) {
return <View style={{width: EMPTY_ITEM_LENGTH}} />;
}
const inputRange = [
(index - 2) * ITEM_LENGTH,
(index - 1) * ITEM_LENGTH,
index * ITEM_LENGTH,
];
const translateY = scrollX.interpolate({
inputRange,
outputRange: [
CURRENT_ITEM_TRANSLATE_Y * 2,
CURRENT_ITEM_TRANSLATE_Y,
CURRENT_ITEM_TRANSLATE_Y * 2,
],
extrapolate: 'clamp',
});
return (
<View style={{width: ITEM_LENGTH}}>
<Animated.View
style={[
{
transform: [{translateY}],
},
styles.itemContent,
]}>
<Image source={{uri: item.uri}} style={styles.itemImage} />
<Text style={styles.itemText} numberOfLines={1}>
{item.title}
</Text>
</Animated.View>
</View>
);
}}
getItemLayout={getItemLayout}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item.id}
bounces={false}
decelerationRate={0}
renderToHardwareTextureAndroid
contentContainerStyle={styles.flatListContent}
snapToInterval={ITEM_LENGTH}
snapToAlignment="start"
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {x: scrollX}}}],
{useNativeDriver: false},
)}
scrollEventThrottle={16}
/>
</View>
);
};
export default ImageCarousel;
const styles = StyleSheet.create({
container: {},
flatListContent: {
height: CURRENT_ITEM_TRANSLATE_Y * 2 + ITEM_LENGTH,
alignItems: 'center',
marginBottom: CURRENT_ITEM_TRANSLATE_Y,
},
itemContent: {
marginHorizontal: SPACING * 3,
alignItems: 'center',
backgroundColor: 'white',
borderRadius: BORDER_RADIUS + SPACING * 2,
},
itemText: {
fontSize: 24,
position: 'absolute',
bottom: SPACING * 2,
right: SPACING * 2,
color: 'white',
fontWeight: '600',
},
itemImage: {
width: '100%',
height: ITEM_LENGTH,
borderRadius: BORDER_RADIUS,
resizeMode: 'cover',
},
});
Reload the application to preview the smooth animations!

Adding Navigation (Next and Previous) Arrow Controls#
Let's introduce arrow controls to give users an alternative way to explore the carousel's items.
The <FlatList />
component's reference comes with a scrollToIndex
method, which tells the component which item in the carousel to scroll to based on the specified index.
All we need to do is track the index of the item currently in the view. Increment it when we scroll right and decrement it when we scroll left.
To figure out which item is currently in the view, we need to specify these two props on the <FlatList />
component:
(src/components/ImageCarousel.tsx
)
onViewableItemsChanged={handleOnViewableItemsChanged}
viewabilityConfig={{itemVisiblePercentThreshold: 100}}
For an item to be considered as currently in the view, it must be 100% visible to the user (no part of it hidden off screen). onViewableItemsChanged
calls a handler function whenever the items currently in the view have changed and tells us which items are now 100% visible in the view (based on the visibility percent threshold).
We'll track the current index via a React ref since we don't need to re-render the component whenever this index changes.
(src/components/ImageCarousel.tsx
)
const currentIndex = useRef<number>(0);
Define the handleOnViewableItemsChanged
handler function. This function checks for the item currently in view and sets the ref's value to this item's index value.
(src/components/ImageCarousel.tsx
)
const handleOnViewableItemsChanged = useCallback(
({viewableItems}) => {
const itemsInView = viewableItems.filter(
({item}: {item: ImageCarouselItem}) => item.uri && item.title,
);
if (itemsInView.length === 0) {
return;
}
currentIndex.current = itemsInView[0].index;
},
[data],
);
Note: The placeholder items are technically 100% in the view when the user moves to the first or last item in the carousel. Therefore, we need to filter out those placeholder items.
Note: Wrap the handleOnViewableItemsChanged
function in a useCallback
hook to avoid the following issue:
Changing onViewableItemsChanged on the fly is not supported
Now, let's create the arrow controls.
Add two flag variables, isNextDisabled
and isPrevDisabled
, to check whether or not the arrow controls should be disabled or not. For instance, the previous arrow control should be disabled when the current item is the first item in the carousel, and the next arrow control should be disabled when the current item is the last item in the carousel.
(src/components/ImageCarousel.tsx
)
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(false);
const [isPrevDisabled, setIsPrevDisabled] = useState<boolean>(false);
Obtain a reference to the <FlatList />
component to access the scrollToIndex
method:
(src/components/ImageCarousel.tsx
)
const flatListRef = useRef<FlatList<any>>(null);
// On the `<FlatList />` component.
ref={flatListRef}
Implement the controls' functionality and style them:
(src/components/ImageCarousel.tsx
)
const handleOnPrev = () => {
if (currentIndex.current === 1) {
return;
}
if (flatListRef.current) {
flatListRef.current.scrollToIndex({
animated: true,
index: currentIndex.current - 1,
});
}
};
const handleOnNext = () => {
if (currentIndex.current === data.length) {
return;
}
if (flatListRef.current) {
flatListRef.current.scrollToIndex({
animated: true,
index: currentIndex.current + 1,
});
}
};
// Placed below the `<FlatList />` component.
<View style={styles.footer}>
<Pressable
onPress={handleOnPrev}
disabled={isPrevDisabled}
style={({pressed}) => [
{
opacity: pressed || isPrevDisabled ? 0.5 : 1.0,
},
styles.arrowBtn,
]}>
<Text
style={styles.arrowBtnText}
accessibilityLabel="Go To Previous Item">
◂
</Text>
</Pressable>
<Text>{' '}</Text>
<Pressable
onPress={handleOnNext}
disabled={isNextDisabled}
style={({pressed}) => [
{
opacity: pressed || isNextDisabled ? 0.5 : 1.0,
},
styles.arrowBtn,
]}>
<Text
style={styles.arrowBtnText}
accessibilityLabel="Go To Next Item">
▸
</Text>
</Pressable>
</View>
const styles = StyleSheet.create({
arrowBtn: {},
arrowBtnText: {
fontSize: 42,
fontWeight: '600',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 10,
},
});
For a consistent scrolling motion between adjacent items via the scrollToIndex
method, specify the getItemLayout
prop on the <FlatList />
component. Since we already know the fixed dimensions of the carousel's items, we should let the <FlatList />
component know these dimensions so that it doesn't need to dynamically measure the items' dimensions and have scrollToIndex
always scroll to a target item correctly.
(src/components/ImageCarousel.tsx
)
// `data` perameter is not used. Therefore, it is annotated with the `any` type to merely satisfy the linter.
const getItemLayout = (_data: any, index: number) => ({
length: ITEM_LENGTH,
offset: ITEM_LENGTH * (index - 1),
index,
});
// On the `<FlatList />` component.
getItemLayout={getItemLayout}
Altogether...
(src/components/ImageCarousel.tsx
)
import React, {FC, useCallback, useEffect, useRef, useState} from 'react';
import {
Animated,
Dimensions,
FlatList,
Image,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
const {width} = Dimensions.get('window');
const SPACING = 5;
const ITEM_LENGTH = width * 0.8; // Item is a square. Therefore, its height and width are of the same length.
const EMPTY_ITEM_LENGTH = (width - ITEM_LENGTH) / 2;
const BORDER_RADIUS = 20;
const CURRENT_ITEM_TRANSLATE_Y = 48;
interface ImageCarouselProps {
data: ImageCarouselItem[];
}
const ImageCarousel: FC<ImageCarouselProps> = ({data}) => {
const scrollX = useRef(new Animated.Value(0)).current;
const [dataWithPlaceholders, setDataWithPlaceholders] = useState<
ImageCarouselItem[]
>([]);
const currentIndex = useRef<number>(0);
const flatListRef = useRef<FlatList<any>>(null);
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(false);
const [isPrevDisabled, setIsPrevDisabled] = useState<boolean>(false);
useEffect(() => {
setDataWithPlaceholders([{id: -1}, ...data, {id: data.length}]);
currentIndex.current = 1;
setIsPrevDisabled(true);
}, [data]);
const handleOnViewableItemsChanged = useCallback(
({viewableItems}) => {
const itemsInView = viewableItems.filter(
({item}: {item: ImageCarouselItem}) => item.uri && item.title,
);
if (itemsInView.length === 0) {
return;
}
currentIndex.current = itemsInView[0].index;
setIsNextDisabled(currentIndex.current === data.length);
setIsPrevDisabled(currentIndex.current === 1);
},
[data],
);
const handleOnPrev = () => {
if (currentIndex.current === 1) {
return;
}
if (flatListRef.current) {
flatListRef.current.scrollToIndex({
animated: true,
index: currentIndex.current - 1,
});
}
};
const handleOnNext = () => {
if (currentIndex.current === data.length) {
return;
}
if (flatListRef.current) {
flatListRef.current.scrollToIndex({
animated: true,
index: currentIndex.current + 1,
});
}
};
// `data` perameter is not used. Therefore, it is annotated with the `any` type to merely satisfy the linter.
const getItemLayout = (_data: any, index: number) => ({
length: ITEM_LENGTH,
offset: ITEM_LENGTH * (index - 1),
index,
});
return (
<View style={styles.container}>
<FlatList
ref={flatListRef}
data={dataWithPlaceholders}
renderItem={({item, index}) => {
if (!item.uri || !item.title) {
return <View style={{width: EMPTY_ITEM_LENGTH}} />;
}
const inputRange = [
(index - 2) * ITEM_LENGTH,
(index - 1) * ITEM_LENGTH,
index * ITEM_LENGTH,
];
const translateY = scrollX.interpolate({
inputRange,
outputRange: [
CURRENT_ITEM_TRANSLATE_Y * 2,
CURRENT_ITEM_TRANSLATE_Y,
CURRENT_ITEM_TRANSLATE_Y * 2,
],
extrapolate: 'clamp',
});
return (
<View style={{width: ITEM_LENGTH}}>
<Animated.View
style={[
{
transform: [{translateY}],
},
styles.itemContent,
]}>
<Image source={{uri: item.uri}} style={styles.itemImage} />
<Text style={styles.itemText} numberOfLines={1}>
{item.title}
</Text>
</Animated.View>
</View>
);
}}
getItemLayout={getItemLayout}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item.id}
bounces={false}
decelerationRate={0}
renderToHardwareTextureAndroid
contentContainerStyle={styles.flatListContent}
snapToInterval={ITEM_LENGTH}
snapToAlignment="start"
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {x: scrollX}}}],
{useNativeDriver: false},
)}
scrollEventThrottle={16}
onViewableItemsChanged={handleOnViewableItemsChanged}
viewabilityConfig={{
itemVisiblePercentThreshold: 100,
}}
/>
<View style={styles.footer}>
<Pressable
onPress={handleOnPrev}
disabled={isPrevDisabled}
style={({pressed}) => [
{
opacity: pressed || isPrevDisabled ? 0.5 : 1.0,
},
styles.arrowBtn,
]}>
<Text
style={styles.arrowBtnText}
accessibilityLabel="Go To Previous Item">
◂
</Text>
</Pressable>
<Text>{' '}</Text>
<Pressable
onPress={handleOnNext}
disabled={isNextDisabled}
style={({pressed}) => [
{
opacity: pressed || isNextDisabled ? 0.5 : 1.0,
},
styles.arrowBtn,
]}>
<Text
style={styles.arrowBtnText}
accessibilityLabel="Go To Next Item">
▸
</Text>
</Pressable>
</View>
</View>
);
};
export default ImageCarousel;
const styles = StyleSheet.create({
container: {},
arrowBtn: {},
arrowBtnText: {
fontSize: 42,
fontWeight: '600',
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 10,
},
flatListContent: {
height: CURRENT_ITEM_TRANSLATE_Y * 2 + ITEM_LENGTH,
alignItems: 'center',
marginBottom: CURRENT_ITEM_TRANSLATE_Y,
},
item: {},
itemContent: {
marginHorizontal: SPACING * 3,
alignItems: 'center',
backgroundColor: 'white',
borderRadius: BORDER_RADIUS + SPACING * 2,
},
itemText: {
fontSize: 24,
position: 'absolute',
bottom: SPACING * 2,
right: SPACING * 2,
color: 'white',
fontWeight: '600',
},
itemImage: {
width: '100%',
height: ITEM_LENGTH,
borderRadius: BORDER_RADIUS,
resizeMode: 'cover',
},
});
Reload the application. Here's how the carousel should look and behave:
Scrolling via Dragging

Navigating via Arrow Buttons


For the final version of this project, click this link for the GitHub repository.
Next Steps#
Try implementing your own image carousel with React Native's <FlatList />
component. For more about building apps with React Native, check out our new course The newline Guide to React Native for JavaScript Developer.
