Parallax Carousel

A horizontally paging carousel where images shift at a different speed than the scroll

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-haptics react-native-worklets

Copy and paste the following code into your project. component/molecules/parallax-carousel

import React from "react";import { View, StyleSheet, Dimensions } from "react-native";import Animated, {  useSharedValue,  useAnimatedScrollHandler,  useAnimatedStyle,  interpolate,} from "react-native-reanimated";import type {  ParallaxCarouselItem,  ParallaxCarouselItemProps,  ParallaxCarouselProps,} from "./types";import { scheduleOnRN } from "react-native-worklets";import { impactAsync, ImpactFeedbackStyle } from "expo-haptics";const { width, height } = Dimensions.get("window");const ParallaxCarouselItemComponent = <ItemT extends ParallaxCarouselItem>({  item,  index,  scrollX,  renderItem,  itemWidth,  itemHeight,  spacing,  parallaxIntensity,}: ParallaxCarouselItemProps<ItemT>) => {  const inputRange = [    (index - 1) * itemWidth,    index * itemWidth,    (index + 1) * itemWidth,  ];  const imageAnimatedStyle = useAnimatedStyle(() => {    const translateX = interpolate(scrollX.value, inputRange, [      -itemWidth * parallaxIntensity,      0,      itemWidth * parallaxIntensity,    ]);    return {      transform: [{ translateX }],    };  });  return (    <View      style={[styles.itemContainer, { width: itemWidth, height: itemHeight }]}    >      <View        style={[          styles.imageContainer,          { width: itemWidth - spacing * 2, height: itemHeight - spacing * 2 },        ]}      >        {item.image && (          <Animated.Image            source={item.image}            style={[              styles.image,              {                width: (itemWidth - spacing * 2) * 1,                height: itemHeight - spacing * 2,              },              imageAnimatedStyle,            ]}          />        )}      </View>      {renderItem({ item, index })}    </View>  );};const ParallaxCarousel = <ItemT extends ParallaxCarouselItem>({  data,  renderItem,  keyExtractor,  itemWidth = width,  itemHeight = height * 0.75,  spacing = 20,  parallaxIntensity = 0.7,  pagingEnabled = true,  showHorizontalScrollIndicator = false,}: ParallaxCarouselProps<ItemT>) => {  const scrollX = useSharedValue(0);  const onScroll = useAnimatedScrollHandler({    onScroll: (event) => {      scrollX.value = event.contentOffset.x;    },    onEndDrag: () => {      scheduleOnRN(impactAsync, ImpactFeedbackStyle.Rigid);    },  });  const defaultKeyExtractor = (item: ItemT, index: number) =>    keyExtractor ? keyExtractor(item, index) : `item-${index}`;  return (    <View style={styles.carouselWrapper}>      <Animated.FlatList        data={data}        keyExtractor={defaultKeyExtractor}        horizontal        pagingEnabled={pagingEnabled}        showsHorizontalScrollIndicator={showHorizontalScrollIndicator}        onScroll={onScroll}        style={{ flexGrow: 0 }}        scrollEventThrottle={16}        contentContainerStyle={styles.flatListContent}        renderItem={({ item, index }) => (          <ParallaxCarouselItemComponent            item={item}            index={index}            scrollX={scrollX}            renderItem={renderItem}            itemWidth={itemWidth}            itemHeight={itemHeight}            spacing={spacing}            parallaxIntensity={parallaxIntensity}          />        )}      />    </View>  );};const styles = StyleSheet.create({  carouselWrapper: {    flex: 1,    justifyContent: "center",    alignItems: "center",  },  flatListContent: {    alignItems: "center",  },  itemContainer: {    justifyContent: "center",    alignItems: "center",  },  imageContainer: {    overflow: "hidden",    borderRadius: 20,    backgroundColor: "transparent",    justifyContent: "center",    alignItems: "center",  },  image: {    resizeMode: "cover",  },});export {  ParallaxCarousel,  ParallaxCarouselItemProps,  ParallaxCarouselProps,  ParallaxCarouselItem,};

Usage

import { View, Text, StyleSheet, Image, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { useState } from "react";import { CircularCarousel } from "@/components/molecules/circular-carousel";import { LinearGradient } from "expo-linear-gradient";import MaterialCarousel from "@/components/molecules/material-carousel";import { MorphicTabBar } from "@/components/molecules/morphing-tabbar";import { ParallaxCarousel } from "@/components/molecules/parallax-carousel";const { width: SCREEN_WIDTH } = Dimensions.get("window");export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    StretchPro: require("@/assets/fonts/StretchPro.otf"),  });  const [currentIndex, setCurrentIndex] = useState<number>(0);  const DATA = [    {      id: "1",      name: "MY DEAR MELANCHOLY",      artist: "The Weeknd",      year: "2018",      image:        "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg",    },    {      id: "2",      name: "RANDOM ACCESS MEMORIES",      artist: "Daft Punk",      year: "2013",      image:        "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg",    },    {      id: "3",      name: "CURRENTS",      artist: "Tame Impala",      year: "2015",      image:        "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg",    },    {      id: "4",      name: "PLASTIC BEACH",      artist: "Gorillaz",      year: "2010",      image:        "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg",    },  ];  const ITEMS: string[] = [    "https://i.pinimg.com/736x/bb/6e/1e/bb6e1e3d8b8e6720e0f157e1ee2196e1.jpg",    "https://i.pinimg.com/736x/a9/74/57/a97457c911867c0d50d5bcedf6316cd4.jpg",    "https://i.pinimg.com/736x/2d/eb/96/2deb969a74ca7ae7207f0795928ca048.jpg",    "https://i.pinimg.com/736x/e3/6a/00/e36a009c0b99b0ada7646c09a4a3ff6b.jpg",  ];  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="inverted" />      <View style={styles.header}>        <View>          <Text            style={[styles.title, fontLoaded && { fontFamily: "StretchPro" }]}          >            POSTERSS          </Text>          <Text            style={[              styles.subtitle,              fontLoaded && { fontFamily: "HelveticaNowDisplay" },            ]}          >            Recent posters collection.          </Text>        </View>        <View style={styles.headerRight}>          <SymbolView name="macwindow.stack" size={20} tintColor="#fff" />        </View>      </View>      <ParallaxCarousel        data={ITEMS.map((v) => ({ image: { uri: v } }))}        renderItem={() => <></>}        parallaxIntensity={0.9}      />    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    flexDirection: "row",    justifyContent: "space-between",    alignItems: "center",    paddingHorizontal: 24,    paddingTop: 70,  },  title: {    fontSize: 28,    color: "#fff",    letterSpacing: 2,  },  subtitle: {    fontSize: 12,    color: "#aaa",  },  headerRight: {    width: 40,    height: 40,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",  },  card: {    width: "100%",    height: 340,    borderRadius: 24,    overflow: "hidden",  },  cardImage: {    width: 300,    height: "100%",    resizeMode: "cover",  },  cardGradient: {    ...StyleSheet.absoluteFillObject,  },  cardContent: {    position: "absolute",    bottom: 0,    left: 0,    right: 0,    padding: 20,    gap: 8,  },  albumName: {    fontSize: 16,    color: "#fff",    letterSpacing: 1,  },  artistRow: {    flexDirection: "row",    alignItems: "center",    gap: 6,  },  artistText: {    fontSize: 13,    color: "rgba(255,255,255,0.7)",  },  dot: {    width: 3,    height: 3,    borderRadius: 1.5,    backgroundColor: "rgba(255,255,255,0.4)",  },  yearText: {    fontSize: 13,    color: "rgba(255,255,255,0.5)",  },  footer: {    paddingHorizontal: 24,    marginTop: 32,    gap: 20,  },  nowPlaying: {    flexDirection: "row",    alignItems: "center",    justifyContent: "space-between",    backgroundColor: "#141414",    padding: 12,    borderRadius: 16,  },  nowPlayingLeft: {    flexDirection: "row",    alignItems: "center",    gap: 12,    flex: 1,  },  nowPlayingImage: {    width: 48,    height: 48,    borderRadius: 10,  },  nowPlayingInfo: {    flex: 1,    gap: 2,  },  nowPlayingTitle: {    fontSize: 14,    fontWeight: "600",    color: "#fff",  },  nowPlayingArtist: {    fontSize: 12,    color: "#666",  },  nowPlayingControls: {    width: 44,    height: 44,    borderRadius: 22,    backgroundColor: "#fff",    justifyContent: "center",    alignItems: "center",  },  dots: {    flexDirection: "row",    justifyContent: "center",    alignItems: "center",    gap: 6,  },  dotIndicator: {    width: 6,    height: 6,    borderRadius: 3,    backgroundColor: "#333",  },  dotIndicatorActive: {    width: 20,    backgroundColor: "#fff",  },});

Props

ParallaxCarouselItemProps

React Native Reanimated
Expo Haptics
React Native Worklets