Circular Carousel

A circular style carousel where the cards rotate, scale and blur

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-blur react-native-worklets

Copy and paste the following code into your project. component/molecules/circular-carousel

import { Dimensions, StyleSheet, View } from "react-native";import React from "react";import { CircularCarouselItemProps, CircularCarouselProps } from "./types";import { BlurView, type BlurViewProps } from "expo-blur";import Animated, {  interpolate,  useAnimatedScrollHandler,  useAnimatedStyle,  useSharedValue,  Extrapolation,  useAnimatedProps,  useDerivedValue,} from "react-native-reanimated";import { scheduleOnRN } from "react-native-worklets";const AnimatedBlurView =  Animated.createAnimatedComponent<BlurViewProps>(BlurView);const { width: SCREEN_WIDTH } = Dimensions.get("window");const ITEM_WIDTH = SCREEN_WIDTH * 0.75;const SPACING = 20;const SIDE_SPACING = (SCREEN_WIDTH - ITEM_WIDTH) / 2;const CarouselItem = <ItemT,>({  item,  index,  scrollX,  renderItem,  itemWidth = ITEM_WIDTH,  spacing = SPACING,  dataLength,}: CircularCarouselItemProps<ItemT>) => {  const itemWidthWithSpacing = itemWidth + spacing;  const inputRange = [    (index - 2) * itemWidthWithSpacing,    (index - 1) * itemWidthWithSpacing,    index * itemWidthWithSpacing,    (index + 1) * itemWidthWithSpacing,    (index + 2) * itemWidthWithSpacing,  ];  const animatedStyle = useAnimatedStyle(() => {    const translateYOutputRange = [      itemWidth / 4,      itemWidth / 8,      0,      itemWidth / 8,      itemWidth / 4,    ];    const opacityOutputRange = [0.5, 0.8, 1, 0.8, 0.5];    const scaleOutputRange = [0.75, 0.85, 1, 0.85, 0.75];    const rotateZOutputRange = [40, 20, 0, -20, -40];    const translateY = interpolate(      scrollX.value,      inputRange,      translateYOutputRange,      Extrapolation.CLAMP,    );    const opacity = interpolate(      scrollX.value,      inputRange,      opacityOutputRange,      Extrapolation.CLAMP,    );    const scale = interpolate(      scrollX.value,      inputRange,      scaleOutputRange,      Extrapolation.CLAMP,    );    const rotateZ = interpolate(      scrollX.value,      inputRange,      rotateZOutputRange,      Extrapolation.CLAMP,    );    return {      opacity,      transform: [{ translateY }, { scale }, { rotateZ: `${rotateZ}deg` }],    };  });  const animatedBlurProps = useAnimatedProps(() => {    const blurIntensity = interpolate(      scrollX.value,      inputRange,      [80, 40, 0, 40, 80],      Extrapolation.CLAMP,    );    return {      intensity: blurIntensity,    };  });  return (    <Animated.View      style={[        styles.itemContainer,        animatedStyle,        { width: itemWidth, marginHorizontal: spacing / 2 },        {          marginRight:            index === (dataLength ? dataLength - 1 : 0)              ? SIDE_SPACING - spacing / 2              : undefined,        },      ]}    >      <View style={styles.contentWrapper}>        {renderItem({ item, index })}        <AnimatedBlurView          animatedProps={animatedBlurProps}          style={[StyleSheet.absoluteFill, styles.blurOverlay]}          tint="prominent"        />      </View>    </Animated.View>  );};const CircularCarousel = <ItemT,>({  data,  renderItem,  horizontalSpacing = SIDE_SPACING,  itemWidth = ITEM_WIDTH,  spacing = SPACING,  onIndexChange,}: CircularCarouselProps<ItemT>) => {  const scrollX = useSharedValue(0);  const previousIndex = useSharedValue(-1);  const itemWidthWithSpacing = itemWidth + spacing;  useDerivedValue(() => {    const currentIndex = Math.round(scrollX.value / itemWidthWithSpacing);    if (currentIndex !== previousIndex.value && previousIndex.value !== -1) {      if (onIndexChange) {        scheduleOnRN<[number], void>(onIndexChange, currentIndex);      }    }    previousIndex.value = currentIndex;  }, []);  const onScroll = useAnimatedScrollHandler<Record<string, unknown>>({    onScroll: (event) => {      scrollX.value = event.contentOffset.x;    },  });  return (    <Animated.FlatList      data={data}      showsHorizontalScrollIndicator={false}      onScroll={onScroll}      scrollEventThrottle={16}      keyExtractor={(_, index) => index.toString()}      horizontal      pagingEnabled      snapToInterval={itemWidthWithSpacing}      decelerationRate="fast"      contentContainerStyle={{        paddingHorizontal: horizontalSpacing - spacing / 2,        marginBottom: 20,        marginTop: 40,      }}      style={{        flexGrow: 0,        bottom: 2,      }}      renderItem={({ item, index }) => (        <CarouselItem          item={item}          index={index}          dataLength={data.length}          scrollX={scrollX}          renderItem={renderItem}          itemWidth={itemWidth}          spacing={spacing}        />      )}    />  );};const styles = StyleSheet.create({  itemContainer: {    justifyContent: "center",    alignItems: "center",  },  contentWrapper: {    overflow: "hidden",    borderRadius: 24,    backgroundColor: "white",    shadowColor: "#000",    shadowOffset: {      width: 0,      height: 4,    },    shadowOpacity: 0.1,    shadowRadius: 12,    elevation: 5,  },  blurOverlay: {    borderRadius: 24,  },});export { CircularCarousel };

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 { SymbolView } from "expo-symbols";import { useState } from "react";import { CircularCarousel } from "@/components/molecules/circular-carousel";import { LinearGradient } from "expo-linear-gradient";const { width: SCREEN_WIDTH } = 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 [currentIndex, setCurrentIndex] = useState<number>(0);  const DATA = [    {      id: "1",      name: "MY DEAR MELANCHOLY",      artist: "The Weeknd",      year: "2018",      image:        "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg",    },    {      id: "2",      name: "RANDOM ACCESS MEMORIES",      artist: "Daft Punk",      year: "2013",      image:        "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg",    },    {      id: "3",      name: "CURRENTS",      artist: "Tame Impala",      year: "2015",      image:        "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg",    },    {      id: "4",      name: "PLASTIC BEACH",      artist: "Gorillaz",      year: "2010",      image:        "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg",    },  ];  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="inverted" />      <View style={styles.header}>        <View>          <Text            style={[styles.title, fontLoaded && { fontFamily: "StretchPro" }]}          >            ALBBUMS          </Text>          <Text            style={[              styles.subtitle,              fontLoaded && { fontFamily: "HelveticaNowDisplay" },            ]}          >            Here are your recent albums.          </Text>        </View>        <View style={styles.headerRight}>          <SymbolView            name="line.3.horizontal.decrease"            size={20}            tintColor="#fff"          />        </View>      </View>      <CircularCarousel        data={DATA}        itemWidth={SCREEN_WIDTH * 0.7_5}        spacing={1}        onIndexChange={setCurrentIndex}        renderItem={({ item }) => (          <View style={styles.card}>            <Image source={{ uri: item.image }} style={styles.cardImage} />            <LinearGradient              colors={["transparent", "rgba(0,0,0,0.6)"]}              style={styles.cardGradient}            />            <View style={styles.cardContent}>              <Text                style={[                  styles.albumName,                  fontLoaded && { fontFamily: "StretchPro" },                ]}                numberOfLines={2}              >                {item.name}              </Text>              <View style={styles.artistRow}>                <SymbolView                  name="music.mic"                  size={12}                  tintColor="rgba(255,255,255,0.6)"                />                <Text                  style={[                    styles.artistText,                    fontLoaded && { fontFamily: "SfProRounded" },                  ]}                >                  {item.artist}                </Text>                <View style={styles.dot} />                <Text                  style={[                    styles.yearText,                    fontLoaded && { fontFamily: "SfProRounded" },                  ]}                >                  {item.year}                </Text>              </View>            </View>          </View>        )}      />      <View style={styles.footer}>        <View style={styles.nowPlaying}>          <View style={styles.nowPlayingLeft}>            <Image              source={{ uri: DATA[currentIndex]?.image }}              style={styles.nowPlayingImage}            />            <View style={styles.nowPlayingInfo}>              <Text                style={[                  styles.nowPlayingTitle,                  fontLoaded && { fontFamily: "HelveticaNowDisplay" },                ]}                numberOfLines={1}              >                {DATA[currentIndex]?.name}              </Text>              <Text                style={[                  styles.nowPlayingArtist,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                {DATA[currentIndex]?.artist}              </Text>            </View>          </View>          <View style={styles.nowPlayingControls}>            <SymbolView name="play.fill" size={18} tintColor="#000000" />          </View>        </View>      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    flexDirection: "row",    justifyContent: "space-between",    alignItems: "center",    paddingHorizontal: 24,    paddingTop: 70,    paddingBottom: 32,  },  title: {    fontSize: 28,    color: "#fff",    letterSpacing: 2,  },  subtitle: {    fontSize: 12,    color: "#aaa",  },  headerRight: {    width: 40,    height: 40,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",  },  card: {    width: "100%",    height: 340,    borderRadius: 24,    overflow: "hidden",  },  cardImage: {    width: 300,    height: "100%",    resizeMode: "cover",  },  cardGradient: {    ...StyleSheet.absoluteFillObject,  },  cardContent: {    position: "absolute",    bottom: 0,    left: 0,    right: 0,    padding: 20,    gap: 8,  },  albumName: {    fontSize: 16,    color: "#fff",    letterSpacing: 1,  },  artistRow: {    flexDirection: "row",    alignItems: "center",    gap: 6,  },  artistText: {    fontSize: 13,    color: "rgba(255,255,255,0.7)",  },  dot: {    width: 3,    height: 3,    borderRadius: 1.5,    backgroundColor: "rgba(255,255,255,0.4)",  },  yearText: {    fontSize: 13,    color: "rgba(255,255,255,0.5)",  },  footer: {    paddingHorizontal: 24,    marginTop: 32,    gap: 20,  },  nowPlaying: {    flexDirection: "row",    alignItems: "center",    justifyContent: "space-between",    backgroundColor: "#141414",    padding: 12,    borderRadius: 16,  },  nowPlayingLeft: {    flexDirection: "row",    alignItems: "center",    gap: 12,    flex: 1,  },  nowPlayingImage: {    width: 48,    height: 48,    borderRadius: 10,  },  nowPlayingInfo: {    flex: 1,    gap: 2,  },  nowPlayingTitle: {    fontSize: 14,    fontWeight: "600",    color: "#fff",  },  nowPlayingArtist: {    fontSize: 12,    color: "#666",  },  nowPlayingControls: {    width: 44,    height: 44,    borderRadius: 22,    backgroundColor: "#fff",    justifyContent: "center",    alignItems: "center",  },  dots: {    flexDirection: "row",    justifyContent: "center",    alignItems: "center",    gap: 6,  },  dotIndicator: {    width: 6,    height: 6,    borderRadius: 3,    backgroundColor: "#333",  },  dotIndicatorActive: {    width: 20,    backgroundColor: "#fff",  },});

Props

CircularCarouselItemProps

React Native Reanimated
Expo Blur
React Native Worklets