Vertical Flow Carousel
A vertical snapping carousel
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-blurCopy and paste the following code into your project.
component/molecules/vertical-flow-carousel
import { BlurView } from "expo-blur";import React from "react";import { Dimensions, StyleSheet, View } from "react-native";import Animated, { Extrapolation, interpolate, SharedValue, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue,} from "react-native-reanimated";import type { AnimatedItemProps, VerticalCarouselProps } from "./types";const { height: SCREEN_HEIGHT } = Dimensions.get("window");function AnimatedItem<T>({ item, index, scrollY, itemHeight, spacing, rotationAngle, scaleInactive, opacityInactive, showBlur, blurIntensity, children, totalItems,}: AnimatedItemProps<T>) { const animatedStyle = useAnimatedStyle(() => { const inputRange = [ (index - 1) * (itemHeight + spacing), index * (itemHeight + spacing), (index + 1) * (itemHeight + spacing), ]; const scale = interpolate( scrollY.value, inputRange, [scaleInactive, 1, scaleInactive], Extrapolation.CLAMP, ); const opacity = interpolate( scrollY.value, inputRange, [opacityInactive, 1, opacityInactive], Extrapolation.CLAMP, ); const rotateZ = interpolate( scrollY.value, inputRange, [rotationAngle, 0, -rotationAngle], Extrapolation.CLAMP, ); return { transform: [ { scale }, { rotateZ: `${rotateZ}deg` }, { perspective: 1000 }, ], opacity, }; }); const blurOpacity = useAnimatedStyle(() => { const inputRange = [ (index - 1) * (itemHeight + spacing), index * (itemHeight + spacing), (index + 1) * (itemHeight + spacing), ]; const blur = interpolate( scrollY.value, inputRange, [1, 0, 1], Extrapolation.CLAMP, ); return { opacity: blur, }; }); return ( <Animated.View style={[ styles.itemContainer, animatedStyle, { marginBottom: index === totalItems - 1 ? 400 : spacing, }, ]} > <View style={styles.itemContent}> {children} {showBlur && ( <Animated.View style={[ StyleSheet.absoluteFill, blurOpacity, { overflow: "hidden" }, ]} > <BlurView intensity={blurIntensity} style={[ StyleSheet.absoluteFill, { overflow: "hidden", }, ]} tint="dark" /> </Animated.View> )} </View> </Animated.View> );}export default function VerticalFlowCarousel<T>({ data, renderItem, itemHeight = 120, spacing = 50, containerStyle, contentContainerStyle, showBlur = true, blurIntensity = 16, rotationAngle = 12, scaleInactive = 0.85, opacityInactive = 0.5, snapEnabled = true,}: VerticalCarouselProps<T>) { const scrollY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { scrollY.value = event.contentOffset.y; }, }); return ( <View style={[styles.container, containerStyle]}> <Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16} snapToInterval={snapEnabled ? itemHeight + spacing : undefined} decelerationRate="fast" showsVerticalScrollIndicator={false} contentContainerStyle={[styles.scrollContent, contentContainerStyle]} > {data.map((item, index) => ( <AnimatedItem key={index} item={item} index={index} scrollY={scrollY} itemHeight={itemHeight} spacing={spacing} rotationAngle={rotationAngle} scaleInactive={scaleInactive} opacityInactive={opacityInactive} showBlur={showBlur} blurIntensity={blurIntensity} totalItems={data.length} > {renderItem(item, index)} </AnimatedItem> ))} </Animated.ScrollView> </View> );}const styles = StyleSheet.create({ container: { flex: 1, }, scrollContent: { // paddingHorizontal: 30, }, itemContainer: { width: "100%", }, itemContent: { width: "100%", },});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 VerticalFlowCarousel from "@/components/molecules/vertical-flow-carousel";const { width } = Dimensions.get("window");const DATA = [ { id: "1", title: "Mountain Peak", location: "Switzerland", image: "https://picsum.photos/id/29/800/600", }, { id: "2", title: "Ocean Waves", location: "Maldives", image: "https://picsum.photos/id/28/800/600", }, { id: "3", title: "Forest Trail", location: "Canada", image: "https://picsum.photos/id/15/800/600", }, { id: "4", title: "Desert Dunes", location: "Morocco", image: "https://picsum.photos/id/33/800/600", },];export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <VerticalFlowCarousel data={DATA} itemHeight={210} rotationAngle={12} opacityInactive={0.5} scaleInactive={0.8} showBlur snapEnabled blurIntensity={20} contentContainerStyle={styles.carousel} renderItem={(item) => ( <View style={styles.card}> <Image source={{ uri: item.image }} style={styles.image} /> <View style={styles.overlay}> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > {item.title} </Text> <Text style={[ styles.location, fontLoaded && { fontFamily: "SfProRounded" }, ]} > {item.location} </Text> </View> </View> )} /> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", }, carousel: { paddingTop: 80, paddingHorizontal: 20, paddingBottom: 40, }, card: { width: width - 40, height: 280, borderRadius: 20, overflow: "hidden", }, image: { width: "100%", height: "100%", }, overlay: { position: "absolute", bottom: 0, left: 0, right: 0, padding: 20, backgroundColor: "rgba(0,0,0,0.4)", }, title: { fontSize: 22, color: "#fff", marginBottom: 4, }, location: { fontSize: 14, color: "rgba(255,255,255,0.7)", },});Props
AnimatedItemProps
React Native Reanimated
Expo Blur
