Vertical Page Carousel

A vertically paged carousel with snap scrolling

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @sbaiahmed1/react-native-blur react-native-worklets expo-haptics

Copy 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