Parallax Carousel
A horizontally paging carousel where images shift at a different speed than the scroll
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-haptics react-native-workletsCopy 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
