Tilt Carousel
A horizontal carousel where items tilt and lift as you scroll
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @sbaiahmed1/react-native-blurCopy and paste the following code into your project.
component/molecules/tilt-carousel
import * as React from "react";import { Platform, StyleSheet, View, useWindowDimensions, type ViewStyle,} from "react-native";import Animated, { Extrapolation, interpolate, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, useAnimatedProps,} from "react-native-reanimated";import { BlurView, type BlurViewProps } from "@sbaiahmed1/react-native-blur";import type { ICarousel, ICarouselItem } from "./types";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const CarouselItem = <T,>({ item, index, scrollX, renderItem, itemWidth, itemHeight, marginHorizontal, fullWidth, rotationAngle, translateYValue, transformOrigin, useBlur,}: ICarouselItem<T>): | (React.ReactNode & React.JSX.Element & React.ReactNode) | null => { const animatedStyle = useAnimatedStyle<Pick<ViewStyle, "transform">>(() => { const rotateZ = interpolate( scrollX.value, [(index - 1) * fullWidth, index * fullWidth, (index + 1) * fullWidth], [rotationAngle, 0, -rotationAngle], Extrapolation.CLAMP, ); const translateY = interpolate( scrollX.value, [(index - 1) * fullWidth, index * fullWidth, (index + 1) * fullWidth], [translateYValue, 0, translateYValue], Extrapolation.CLAMP, ); return { transform: [{ rotateZ: `${rotateZ}deg` }, { translateY: translateY }], }; }); const animatedBlurProps = useAnimatedProps<Pick<BlurViewProps, "blurAmount">>( () => { const inputRange = [ (index - 1) * fullWidth, index * fullWidth, (index + 1) * fullWidth, ]; const blurIntensity = interpolate( scrollX.value, inputRange, [25, 0, 25], Extrapolation.CLAMP, ); return { blurAmount: blurIntensity, }; }, ); return ( <Animated.View style={[ styles.itemContainer, { width: itemWidth, height: itemHeight, marginHorizontal: marginHorizontal, transformOrigin: Platform.OS === "android" ? `${itemWidth / 2}px ${itemHeight}px` : transformOrigin, }, animatedStyle, ]} > {renderItem({ item, index, scrollX, itemWidth, itemHeight, marginHorizontal, fullWidth, })} {useBlur && ( <AnimatedBlurView style={[StyleSheet.absoluteFillObject, styles.blurOverlay]} animatedProps={animatedBlurProps} blurType="regular" /> )} </Animated.View> );};export const TiltCarousel = <T,>({ data, renderItem, itemWidth = 250, itemHeight = 400, marginHorizontal = 20, rotationAngle = 20, translateYValue = 60, transformOrigin = "bottom", useBlur = false, keyExtractor, scrollEventThrottle = 16, decelerationRate = "fast", showsHorizontalScrollIndicator = false,}: ICarousel<T>): | (React.ReactNode & React.JSX.Element & React.ReactNode) | null => { const { width } = useWindowDimensions(); const scrollX = useSharedValue<number>(0); const fullWidth = itemWidth + marginHorizontal * 2; const spacerWidth = (width - fullWidth) / 2; const onScroll = useAnimatedScrollHandler<Record<string, unknown>>({ onScroll: (event) => { scrollX.value = event.contentOffset.x; }, }); return ( <Animated.FlatList onScroll={onScroll} ListHeaderComponent={<View />} ListHeaderComponentStyle={{ width: spacerWidth }} ListFooterComponent={<View />} contentContainerStyle={{ alignItems: "center", marginTop: 40, marginBottom: 40, }} ListFooterComponentStyle={{ width: spacerWidth }} data={data} showsHorizontalScrollIndicator={showsHorizontalScrollIndicator} style={{ flexGrow: 0 }} keyExtractor={ keyExtractor ? (item, index) => keyExtractor(item, index) : (_, index) => index.toString() } renderItem={({ item, index, }): React.JSX.Element & React.ReactNode & React.ReactElement => { return ( <CarouselItem item={item} index={index} scrollX={scrollX} renderItem={renderItem} itemWidth={itemWidth} itemHeight={itemHeight} marginHorizontal={marginHorizontal} fullWidth={fullWidth} rotationAngle={rotationAngle} translateYValue={translateYValue} transformOrigin={transformOrigin} useBlur={useBlur} /> ); }} horizontal scrollEventThrottle={scrollEventThrottle} decelerationRate={decelerationRate} snapToInterval={fullWidth} /> );};const styles = StyleSheet.create({ itemContainer: { borderRadius: 12, overflow: "hidden", }, blurOverlay: { borderRadius: 12, },});Usage
import { View, Text, StyleSheet, Image } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { TiltCarousel } from "@/components/molecules/tilt-carousel";import { LinearGradient } from "expo-linear-gradient";interface Album { id: string; image: string; title: string; artist: string;}export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"), StretchPro: require("@/assets/fonts/StretchPro.otf"), }); const albums: Album[] = [ { id: "1", image: "https://i.scdn.co/image/ab67616d0000b273c5276ed6cb0287df8d9be07f", title: "Kill Bill", artist: "SZA", }, { id: "2", image: "https://m.media-amazon.com/images/I/91CcNMcqAxL._UF1000,1000_QL80_.jpg", title: "Plastic Beach", artist: "Gorillaz", }, { id: "3", image: "https://f4.bcbits.com/img/a3454468726_5.jpg", title: "Radical Optimism", artist: "Dua Lipa", }, { id: "4", image: "https://www.roughtrade.com/_next/image?url=https%3A%2F%2Fcdn.shopify.com%2Fs%2Ffiles%2F1%2F0867%2F1120%2F6219%2Ffiles%2F3e7ad5e5-9673-480b-9cbb-e4924836b75e_thumbnail_4096.jpg%3Fv%3D1727333350&w=3840&q=75", title: "Future Nostalgia", artist: "Dua Lipa", }, ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> {/* Background */} <LinearGradient colors={["#000", "#0a0a0a", "#000"]} style={StyleSheet.absoluteFill} /> {/* Carousel */} <View style={styles.carouselContainer}> <TiltCarousel data={albums} itemWidth={350} itemHeight={500} marginHorizontal={20} rotationAngle={16} translateYValue={55} useBlur={true} renderItem={({ item }) => ( <View style={styles.card}> <Image source={{ uri: item.image }} style={styles.image} resizeMode="cover" /> <LinearGradient colors={[ "transparent", "rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)", "rgba(0,0,0,0.95)", ]} style={styles.gradient} locations={[0, 0.5, 0.8, 1]} /> <View style={styles.info}> <Text style={[ styles.trackTitle, { fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined, }, ]} numberOfLines={2} > {item.title} </Text> <Text style={[ styles.artist, { fontFamily: fontLoaded ? "Coolvetica" : undefined }, ]} numberOfLines={1} > {item.artist} </Text> </View> </View> )} keyExtractor={(item) => item.id} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", }, header: { paddingHorizontal: 24, paddingTop: 80, paddingBottom: 20, }, title: { fontSize: 36, color: "#fff", letterSpacing: 6, }, carouselContainer: { marginTop: 50, }, card: { flex: 1, backgroundColor: "#000", borderRadius: 20, overflow: "hidden", borderWidth: 1, borderColor: "#1a1a1a", }, image: { width: "100%", height: "100%", }, gradient: { position: "absolute", bottom: 0, left: 0, right: 0, height: "45%", }, info: { position: "absolute", bottom: 0, left: 0, right: 0, padding: 24, gap: 6, }, trackTitle: { fontSize: 20, fontWeight: "700", color: "#fff", lineHeight: 24, }, artist: { fontSize: 15, color: "rgba(255,255,255,0.7)", letterSpacing: 0.8, },});Props
ICarouselItem
ICarouselRenderItem
React Native Reanimated
React Native Blur
