Scale Carousel
A horizontal carousel where items rotate in 3D as you 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/scale-carousel
import React from "react";import { View, StyleSheet, Dimensions } from "react-native";import Animated, { useSharedValue, useAnimatedScrollHandler, useAnimatedStyle, interpolate,} from "react-native-reanimated";import type { ScaleCarouselItem, ScaleCarouselItemProps, ScaleCarouselProps,} from "./types";import { scheduleOnRN } from "react-native-worklets";import { impactAsync, ImpactFeedbackStyle } from "expo-haptics";const { width, height } = Dimensions.get("window");const ScaleCarouselItemComponent = <ItemT extends ScaleCarouselItem>({ item, index, scrollX, renderItem, itemWidth, itemHeight, spacing, scaleRange, rotationRange,}: ScaleCarouselItemProps<ItemT>) => { const inputRange = [ (index - 1) * itemWidth, index * itemWidth, (index + 1) * itemWidth, ]; const imageAnimatedStyle = useAnimatedStyle(() => { return { transform: [ { scale: interpolate( scrollX.value, [index - 1, index, index + 1], scaleRange!, ), }, { rotate: `${interpolate( scrollX.value, [index - 1, index, index + 1], rotationRange!, )}deg`, }, ], }; }); return ( <View style={[styles.itemContainer, { width: itemWidth, height: itemHeight }]} > <Animated.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, ]} /> )} </Animated.View> {renderItem({ item, index })} </View> );};const ScaleCarousel = <ItemT extends ScaleCarouselItem>({ data, renderItem, keyExtractor, itemWidth = width, itemHeight = height * 0.75, spacing = 20, pagingEnabled = true, showHorizontalScrollIndicator = false, scaleRange = [1.6, 1, 1.6], rotationRange = [15, 0, -10],}: ScaleCarouselProps<ItemT>) => { const scrollX = useSharedValue(0); const onScroll = useAnimatedScrollHandler({ onScroll: (event) => { scrollX.value = event.contentOffset.x / (itemWidth + spacing); }, 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} scrollEventThrottle={16} contentContainerStyle={styles.flatListContent} style={{ flexGrow: 0, }} renderItem={({ item, index }) => ( <ScaleCarouselItemComponent item={item} index={index} scrollX={scrollX} renderItem={renderItem} itemWidth={itemWidth} itemHeight={itemHeight} spacing={spacing} scaleRange={scaleRange} rotationRange={rotationRange} /> )} /> </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", }, image: { resizeMode: "cover", },});export { ScaleCarousel, ScaleCarouselItemProps, ScaleCarouselProps, ScaleCarouselItem,};Usage
import { View, Text, StyleSheet, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { SymbolView } from "expo-symbols";import { useFonts } from "expo-font";import { ScaleCarousel } from "@/components/molecules/scale-carousel";import { LinearGradient } from "expo-linear-gradient";const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = 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 MOVIES = [ { image: { uri: "https://i.pinimg.com/736x/7c/22/18/7c221896796b9c3bc1f462f68957402d.jpg", }, title: "INCEPTION", genre: "Sci-Fi", rating: "8.8", }, { image: { uri: "https://i.pinimg.com/1200x/0b/34/ce/0b34ce2145b475247577a5d438a199b0.jpg", }, title: "INTERSTELLAR", genre: "Adventure", rating: "8.7", }, { image: { uri: "https://i.pinimg.com/1200x/e1/6c/f5/e16cf5df099827fe785e416c65802b2d.jpg", }, title: "BLADE RUNNER", genre: "Thriller", rating: "8.1", }, { image: { uri: "https://i.pinimg.com/736x/13/04/e1/1304e1b74fdbe55bd6c16aa694c447b0.jpg", }, title: "THE MATRIX", genre: "Action", rating: "8.7", }, ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.topBar}> <View style={styles.iconButton}> <SymbolView name="line.3.horizontal" size={20} tintColor="#fff" /> </View> <Text style={[ styles.topTitle, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > CINEMA </Text> <View style={styles.iconButton}> <SymbolView name="magnifyingglass" size={20} tintColor="#fff" /> </View> </View> <ScaleCarousel data={MOVIES} itemWidth={SCREEN_WIDTH} itemHeight={SCREEN_HEIGHT * 0.75} scaleRange={[1.4, 1, 1.4]} rotationRange={[15, 0, -15]} renderItem={({ item }) => ( <View style={styles.movieCard}> <LinearGradient colors={["transparent", "rgba(0,0,0,0.8)"]} style={styles.gradient} /> <View style={styles.movieInfo}> <View style={styles.ratingBadge}> <SymbolView name="star.fill" size={14} tintColor="#FFD700" /> <Text style={[ styles.ratingText, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > {item.rating} </Text> </View> <Text style={[ styles.movieTitle, { fontFamily: fontLoaded ? "StretchPro" : undefined, }, ]} > {item.title} </Text> <View style={styles.genreRow}> <View style={styles.genrePill}> <Text style={[ styles.genreText, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > {item.genre} </Text> </View> </View> </View> </View> )} /> <View style={styles.bottomActions}> <View style={[styles.actionButton, styles.primaryButton]}> <SymbolView name="play.fill" size={20} tintColor="#000" /> <Text style={[ styles.primaryButtonText, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > Watch Now </Text> </View> <View style={styles.actionButton}> <SymbolView name="plus" size={22} tintColor="#fff" /> </View> <View style={styles.actionButton}> <SymbolView name="info.circle" size={22} tintColor="#fff" /> </View> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0d0d0d", }, topBar: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 20, paddingTop: 55, paddingBottom: 15, }, topTitle: { fontSize: 18, fontWeight: "600", color: "#fff", letterSpacing: 3, }, iconButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: "rgba(255,255,255,0.08)", justifyContent: "center", alignItems: "center", }, movieCard: { position: "absolute", bottom: 0, left: 0, right: 0, justifyContent: "flex-end", paddingBottom: 50, paddingHorizontal: 24, }, gradient: { position: "absolute", left: 0, right: 0, bottom: 0, height: "500%", }, movieInfo: { gap: 12, }, ratingBadge: { flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(255,215,0,0.15)", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, alignSelf: "flex-start", }, ratingText: { fontSize: 13, fontWeight: "600", color: "#FFD700", }, movieTitle: { fontSize: 25, fontWeight: "700", color: "#fff", letterSpacing: 1, }, genreRow: { flexDirection: "row", gap: 8, }, genrePill: { backgroundColor: "rgba(255,255,255,0.1)", paddingHorizontal: 14, paddingVertical: 6, borderRadius: 16, borderWidth: 1, borderColor: "rgba(255,255,255,0.15)", }, genreText: { fontSize: 12, color: "rgba(255,255,255,0.7)", fontWeight: "500", }, bottomActions: { flexDirection: "row", paddingHorizontal: 20, paddingBottom: 40, gap: 12, alignItems: "center", }, actionButton: { height: 50, borderRadius: 25, backgroundColor: "rgba(255,255,255,0.08)", justifyContent: "center", alignItems: "center", paddingHorizontal: 20, }, primaryButton: { flex: 1, backgroundColor: "#fff", flexDirection: "row", gap: 8, }, primaryButtonText: { fontSize: 15, fontWeight: "600", color: "#000", },});Props
ScaleCarouselItemProps
React Native Reanimated
Expo Haptics
React Native Worklets
