Circular Carousel
A circular style carousel where the cards rotate, scale and blur
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-blur react-native-workletsCopy 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
