blog logo
CHICIO CODING
Coding. Drawing. Fun.
HomeBlogArtAbout me
blog logo
CHICIO CODING
Coding. Drawing. Fun.

Create a circular carousel with parallax effect in React Native

Let's see how it is possible to create a circular carousel with parallax effect in React Native by leveraging the power of React Native Reanimated.


Recently, during a one-to-one meeting with my engineering manager, Luca D'antona, we discussed some exciting animations we’d like to bring to our mobile apps at lastminute.com. In particular, Luca pointed out how captivating the carousel on the home screen of the official Apple TV app is.
This carousel stands out for its stunning parallax animation. Essentially, as you scroll through the images of TV shows and movies, the images shift based on the scroll position, adding a dynamic sense of motion to the entire experience. In our apps we have been using React Native for quite some time, but animations often have a bad reputation within this framework. Luca challenged the team, asking: “Do you think it’s possible to implement such a cool animation on our daily deals carousel? ChatGPT says it should be possible!”.
I took this challenge personally because I’m passionate about animations—a natural consequence of my love for computer graphics). And here I am today, having successfully implemented the challenge and enhanced our daily deals carousel with this new, beautiful parallax effect.
In this post, I’ll show you how to create a stunning full-screen circular carousel (that you can scroll endlessly) with a parallax effect on the images and an opacity animation for the text descriptions of each item. Below, you can find a video of the final result.

To develop this carousel we will use:

  • Expo
  • React Native Reanimated
  • Expo Linear Gradient

Implementation

Let's start from the implementation of the main carousel component, ParallaxCarousel. This component is a Animated.FlatList responsible for displaying the items and managing the scroll. In particular, it has the responsibility to simulate the circular endless loop during the scroll.

import {FC} from 'react';
import Animated from 'react-native-reanimated';
import {ParallaxCarouselItemData} from "@/parallax-carousel/parallax-carousel-item/parallax-carousel-item-data";
import {ParallaxCarouselItem} from "@/parallax-carousel/parallax-carousel-item/parallax-carousel-item";
import {useCircularCarousel} from "@/parallax-carousel/use-circular-carousel";

export const ParallaxCarousel: FC<{ items: ParallaxCarouselItemData[] }> = ({ items }) => {
    const {
        scrollX,
        flatListRef,
        itemsWithFakeEntries,
        scrollHandler,
        getItemLayout
    } = useCircularCarousel(items);

    return (
        <Animated.FlatList
            ref={flatListRef}
            data={itemsWithFakeEntries}
            keyExtractor={(item, index) => `${item.id}-${index}`}
            horizontal
            showsHorizontalScrollIndicator={false}
            decelerationRate="normal"
            onScroll={scrollHandler}
            scrollEventThrottle={16}
            renderItem={({item, index}: { item: ParallaxCarouselItemData, index: number }) =>
                <ParallaxCarouselItem index={index} scrollX={scrollX} item={item}/>}
            pagingEnabled
            initialScrollIndex={1}
            getItemLayout={getItemLayout}
            bounces={false}
        />
    );
};

How can we do this? From the staring array of ParallaxCarouselItemData, we generate a new one where we add fake entries:

  • one element at the beginning of the array, that is a copy of the last element that you can find in the original array.
  • one element at the end of the array, that is a copy of the first item in the original array

This new array will be the one used as data source by the Animated.FlatList. Now we can implement a scrollHandler using useAnimatedScrollHandler, where we put in place a logic for which when we reach one of these fake entries we added above, we scroll without animation to the corresponding real item. On top of the circular loop logic we also need to store the scrollX offset position into a Reanimated SharedValue. We will use it to implement the parallax and opacity animations.

One final note: to avoid calculation about where we need to "force scroll", we are using the scrollToIndex method of FlatList. This requires implementing also the getItemLayout callback for the flatList that gives the ability to the FlatList to cache in some way the offset of each item (and perform very fast the scrolltoIndex operation).

import {ParallaxCarouselItemData} from "@/parallax-carousel/parallax-carousel-item/parallax-carousel-item-data";
import Animated, {runOnJS, useAnimatedRef, useAnimatedScrollHandler, useSharedValue} from "react-native-reanimated";
import {useMemo} from "react";
import {Dimensions} from "react-native";

const { width } = Dimensions.get('window');

export const useCircularCarousel = (items: ParallaxCarouselItemData[]) => {
    const scrollX = useSharedValue(0);
    const flatListRef = useAnimatedRef<Animated.FlatList<ParallaxCarouselItemData>>();
    const itemsWithFakeEntries = useMemo(
        () => [items[items.length - 1], ...items, items[0]],
        [items]
    );

    const scrollToIndex = (index: number) => {
        flatListRef.current?.scrollToIndex({ index: index, animated: false });
    };

    const scrollHandler = useAnimatedScrollHandler({
        onScroll: (event) => {
            scrollX.value = event.contentOffset.x;
        },
        onMomentumEnd: event => {
            const index = Math.round(event.contentOffset.x / width);

            if (index < 1) {
                runOnJS(scrollToIndex)(items.length);
            } else if (index > items.length) {
                runOnJS(scrollToIndex)(1);
            }
        }
    });

    const getItemLayout= (_: ArrayLike<ParallaxCarouselItemData> | null | undefined, index: number) =>
        ({ length: width, offset: width * index, index })

    return {
        scrollX,
        flatListRef,
        itemsWithFakeEntries,
        scrollHandler,
        getItemLayout
    }
}

Now that we have our carousel in FlatList iin place, we can implement the ParallaxCarouselItem component. This component receive the scrollX offset from the parent, and pass it to the useParallaxWithOpacityAnimations hook to calculate the animation needed. In terms strictly of UI, the component is quite straight forward.
It has an image fullscreen, wrapped by a Animated.View that we will use to apply the parallax animation. A LinearGradient is used to add som contrast with the texts at the bottom of the item, created using Animated.Text views.

import Animated, {SharedValue} from "react-native-reanimated";
import {Dimensions, Image, StyleSheet, View} from "react-native";
import {ParallaxCarouselItemData} from "@/parallax-carousel/parallax-carousel-item/parallax-carousel-item-data";
import {LinearGradient} from 'expo-linear-gradient';
import {
    useParallaxWithOpacityAnimations
} from "@/parallax-carousel/parallax-carousel-item/use-parallax-with-opacity-animations";

const { width, height } = Dimensions.get('window');

export const ParallaxCarouselItem: FC<{ index: number, scrollX: SharedValue<number>; item: ParallaxCarouselItemData }> = ({ index, scrollX, item }) => {
    const {parallaxAnimatedStyle, opacityAnimatedStyle} = useParallaxWithOpacityAnimations(index, scrollX, width);

    return (
        <View style={styles.itemContainer}>
            <Animated.View style={[styles.imageContainer, parallaxAnimatedStyle]}>
                <Image source={{ uri: item.image }} style={styles.image} />
            </Animated.View>
            <LinearGradient
                style={styles.gradient}
                start={{ x: 0, y: 0 }}
                end={{ x: 0, y: 1 }}
                colors={['#00000000', '#000000CC']}
            >
                <View style={styles.caption}>
                    <Animated.Text style={[opacityAnimatedStyle, styles.title]}>{item.title}</Animated.Text>
                    <Animated.Text style={[opacityAnimatedStyle, styles.description]}>{item.description}</Animated.Text>
                </View>
            </LinearGradient>
        </View>
    );
}

const styles = StyleSheet.create({
    itemContainer: {
        width: width,
        justifyContent: 'center',
        alignItems: 'center',
        overflow: 'hidden',
    },
    imageContainer: {
        width: width,
        height: height,
        overflow: 'hidden',
        position: 'absolute',
    },
    image: {
        width: '100%',
        height: '100%',
        resizeMode: 'cover',
    },
    caption: {
        paddingHorizontal: 24,
        position: "absolute",
        bottom: 60,
        display: "flex",
        flexDirection: "column",
        gap: 12,
    },
    title: {
        fontSize: 32,
        fontWeight: "bold",
        color: 'white'
    },
    description: {
        fontSize: 18,
        color: 'white',
        fontStyle: 'italic',
        height: 80
    },
    gradient: {
        height: '40%',
        width: '100%',
        marginTop: 'auto'
    }
});

So let's see the implementation of useParallaxWithOpacityAnimations. In this hook we generate two animated styles using the useAnimatedStyle reanimated hook. Both these styles are based on the same inputRange that spans between an offset, with respect to the entire FlatList width, that include the current carousel item, the previous one and the next one. We use this input range to interpolate the scrollX current value for two different purposes:

  • calculate the translationX we want to apply on the items to simulate the parallax effects. This style is the one applied to the items images. In this way the animation is tied to the scroll, and given how we defined the intervals and the inputRange (far left, center, far right), when the scroll ends, and we are focusing on an item the parallax effect will be 0, and so the item will be perfectly centered (no parallax). It is possible to tweak the 200 (pixel) value used in the output range of translateX to increased/decrease the animation (higher, more movement)
  • calculate the opacity we want to apply to the items text descriptions. The same consideration we made for the translationX applies also here (so in this case item centered/scroll ends, opacity is equal to 1, so visible/no opacity)
import {interpolate, SharedValue, useAnimatedStyle} from "react-native-reanimated";

export const useParallaxWithOpacityAnimations = (
    index: number,
    scrollX: SharedValue<number>,
    width: number
) => {
    const inputRange = [
        (index - 1) * width,
        index * width,
        (index + 1) * width,
    ];

    const parallaxAnimatedStyle = useAnimatedStyle(() => ({
        transform: [{translateX: interpolate(scrollX.value, inputRange, [-200, 0, 200])}],
    }));

    const opacityAnimatedStyle = useAnimatedStyle(() => ({
        opacity: interpolate(scrollX.value, inputRange, [0, 1, 0])
    }));

    return {
        parallaxAnimatedStyle,
        opacityAnimatedStyle,
    }
}

One last note: pay attention to the styles imageContainer and itemContainer, applied to the Animated.View that contains the image and to the View that contains the carousel item. Both these rules have overflow: hidden. This is needed because we are moving around the images, and we don't want them to overlap between each other. We just want the item carousel content to move inside its container, that is basically what a parallax effect is.

We are now ready to add our ParallaxCarousel component to the RootLayout of the expo example app, with some test data.

import {ParallaxCarousel} from "@/parallax-carousel/parallax-carousel";

const items = [
  {
    id: '1',
    image: 'https://static.vecteezy.com/system/resources/previews/012/201/404/non_2x/beautiful-landscape-of-green-tea-plantation-in-the-morning-with-foreground-orange-flowers-2000-tea-plantation-doi-angkhang-mountain-chiangmai-thailand-free-photo.jpg',
    title: 'Morning Serenity',
    description: 'Golden sunlight dances over a lush tea plantation, framed by vibrant orange flowers in the foreground.'
  },
  {
    id: '2',
    image: 'https://d3n8a8pro7vhmx.cloudfront.net/backcountryhunters/pages/10800/attachments/original/1640022886/Arizona_Lake_Mead_National_Recreation_Area_00003.jpg',
    title: 'Desert Oasis',
    description: 'A serene desert lake reflecting the warm hues of the surrounding canyon, an invitation to escape.'
  },
  {
    id: '3',
    image: 'https://kenkoimagingusa.com/cdn/shop/articles/Simple_Landscape_Photography_Tips_With_Tons_of_Impact.jpg?v=1566068838&width=1920',
    title: 'Mountain Majesty',
    description: 'Towering peaks covered in a light mist, standing strong against the backdrop of an endless sky.'
  },
];

export default function RootLayout() {
  return <ParallaxCarousel items={items} />;
}

Conclusion

You can find the full implementation of what I described above in this github repo. It was quite funny to implement this carousel and the parallax animation. I hope I can add more animations during my FriYaY to improve our apps UX emoji-heart.