Vertical Page Carousel
A vertically paged carousel with snap scrolling
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @sbaiahmed1/react-native-blur react-native-worklets expo-hapticsCopy and paste the following code into your project.
component/molecules/vertical-page-carousel
import React from "react";import { View, StyleSheet, Dimensions, Platform } from "react-native";import Animated, { useSharedValue, useAnimatedScrollHandler, useAnimatedStyle, interpolate, Extrapolation, useAnimatedProps,} from "react-native-reanimated";import type { VerticalPageItem, VerticalPageItemProps, VerticalPageProps,} from "./types";import { BlurView } from "@sbaiahmed1/react-native-blur";import { impactAsync, ImpactFeedbackStyle, AndroidHaptics, performAndroidHapticsAsync,} from "expo-haptics";import { scheduleOnRN } from "react-native-worklets";const { height } = Dimensions.get("window");const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);const VerticalPageItemComponent = <ItemT extends VerticalPageItem>({ item, index, scrollY, renderItem, itemHeight, cardMargin, cardSpacing, scaleRange, rotationRange, opacityRange, useBlur,}: VerticalPageItemProps<ItemT>) => { const animatedBlurViewProps = useAnimatedProps(() => { const blurAmount = interpolate( scrollY.value, [index - 1, index, index + 1], [20, 0, 20], Extrapolation.CLAMP, ); return { blurAmount, }; }); const animatedStyle = useAnimatedStyle(() => { const scale = interpolate( scrollY.value, [index - 1, index, index + 1], scaleRange, Extrapolation.CLAMP, ); const opacity = interpolate( scrollY.value, [index - 1, index, index + 1], opacityRange, Extrapolation.CLAMP, ); return { transform: [{ scale }], opacity, }; }); const imageAnimatedStyle = useAnimatedStyle(() => { return { transform: [ { rotate: `${interpolate( scrollY.value, [index - 1, index, index + 1], rotationRange, )}deg`, }, ], }; }); return ( <View style={[ styles.itemContainer, { height: itemHeight + cardSpacing, paddingHorizontal: cardMargin, }, ]} > <Animated.View style={[styles.card, { height: itemHeight }, animatedStyle]} > <Animated.View style={[styles.imageContainer]}> {item.image && ( <Animated.Image source={item.image} style={[styles.image, imageAnimatedStyle]} /> )} </Animated.View> {renderItem({ item, index })} {useBlur && ( <AnimatedBlurView style={StyleSheet.absoluteFill} animatedProps={animatedBlurViewProps} blurType="light" /> )} </Animated.View> </View> );};const VerticalPageCarousel = <ItemT extends VerticalPageItem>({ data, renderItem, keyExtractor, itemHeight = height * 0.7, cardMargin = 20, cardSpacing = 20, pagingEnabled = true, showVerticalScrollIndicator = false, scaleRange = [0.9, 1, 0.9], rotationRange = [0, 0, 0], opacityRange = [0.5, 1, 0.5], useBlur = true,}: VerticalPageProps<ItemT>) => { const scrollY = useSharedValue(0); const onScroll = useAnimatedScrollHandler({ onScroll: (event) => { scrollY.value = event.contentOffset.y / (itemHeight + cardSpacing); }, onEndDrag: () => { if (Platform.OS === "ios") { scheduleOnRN(impactAsync, ImpactFeedbackStyle.Medium); } else { scheduleOnRN(performAndroidHapticsAsync, AndroidHaptics.Confirm); } }, }); const defaultKeyExtractor = (item: ItemT, index: number) => keyExtractor ? keyExtractor(item, index) : `item-${index}`; return ( <View style={styles.carouselWrapper}> <Animated.FlatList data={data} keyExtractor={defaultKeyExtractor} horizontal={false} pagingEnabled={pagingEnabled} showsVerticalScrollIndicator={showVerticalScrollIndicator} onScroll={onScroll} scrollEventThrottle={16} snapToInterval={itemHeight + cardSpacing} decelerationRate="fast" contentContainerStyle={[ styles.flatListContent, { paddingVertical: (height - itemHeight) / 2 - cardSpacing / 2 }, ]} renderItem={({ item, index }) => ( <VerticalPageItemComponent item={item} index={index} scrollY={scrollY} renderItem={renderItem} itemHeight={itemHeight} cardMargin={cardMargin} cardSpacing={cardSpacing} scaleRange={scaleRange} rotationRange={rotationRange} opacityRange={opacityRange} useBlur={useBlur} /> )} /> </View> );};const styles = StyleSheet.create({ carouselWrapper: { flex: 1, backgroundColor: "#000", }, flatListContent: { // paddingVertical is calculated dynamically }, itemContainer: { width: "100%", justifyContent: "center", alignItems: "center", }, card: { width: "100%", borderRadius: 24, overflow: "hidden", backgroundColor: "#1a1a1a", }, imageContainer: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, }, image: { width: "100%", height: "100%", resizeMode: "cover", },});export { VerticalPageCarousel, VerticalPageItemProps, VerticalPageProps, VerticalPageItem,};Usage
import { View, Text, StyleSheet, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { VerticalPageCarousel } from "@/components/molecules/vertical-page-carousel";const { height } = Dimensions.get("window");const DATA = [ { id: "1", name: "MY DEAR MELANCHOLY", artist: "The Weeknd", year: "2018", image: { uri: "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg", }, }, { id: "2", name: "RANDOM ACCESS MEMORIES", artist: "Daft Punk", year: "2013", image: { uri: "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg", }, }, { id: "3", name: "CURRENTS", artist: "Tame Impala", year: "2015", image: { uri: "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg", }, }, { id: "4", name: "PLASTIC BEACH", artist: "Gorillaz", year: "2010", image: { uri: "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg", }, },];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"), }); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <VerticalPageCarousel data={DATA} itemHeight={height * 0.65} cardMargin={14} pagingEnabled cardSpacing={6} scaleRange={[0.88, 1, 0.88]} opacityRange={[0.6, 1, 0.6]} useBlur={true} renderItem={({ item }) => ( <View style={styles.content}> <View style={styles.info}> <Text style={[ styles.year, fontLoaded && { fontFamily: "SfProRounded" }, ]} > {item.year} </Text> <Text style={[ styles.name, fontLoaded && { fontFamily: "StretchPro" }, ]} > {item.name} </Text> <Text style={[ styles.artist, fontLoaded && { fontFamily: "SfProRounded" }, ]} > {item.artist} </Text> </View> </View> )} /> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", }, content: { flex: 1, justifyContent: "flex-end", }, info: { padding: 24, backgroundColor: "rgba(0,0,0,0.5)", }, year: { fontSize: 13, color: "rgba(255,255,255,0.6)", marginBottom: 6, }, name: { fontSize: 20, color: "#fff", marginBottom: 4, }, artist: { fontSize: 15, color: "rgba(255,255,255,0.7)", },});Props
VerticalPageItemProps
React Native Reanimated
React Native Blur
React Native Worklets
Expo Haptics
