Circular List
A circular scrolling image list
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-worklets expo-blur expo-hapticsCopy and paste the following code into your project.
component/molecules/circular-list
import { BlurView } from "expo-blur";import * as Haptics from "expo-haptics";import { FunctionComponent, memo, type FC } from "react";import { Dimensions, StyleSheet, View, Image, ViewStyle } from "react-native";import Animated, { Extrapolation, interpolate, useAnimatedScrollHandler, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring,} from "react-native-reanimated";import { scheduleOnRN } from "react-native-worklets";import type { ICircularList, ICircularListItem } from "./types";const { width: windowWidth } = Dimensions.get("window");const LIST_ITEM_WIDTH = windowWidth / 4;const CircularListItem: FC<ICircularListItem> & FunctionComponent<ICircularListItem> = memo<ICircularListItem>( ({ index, contentOffset, imageUri, scaleEnabled, }: ICircularListItem): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const inputRange = [ (index - 2) * LIST_ITEM_WIDTH, (index - 1) * LIST_ITEM_WIDTH, index * LIST_ITEM_WIDTH, (index + 1) * LIST_ITEM_WIDTH, (index + 2) * LIST_ITEM_WIDTH, ]; const scaleOutputRange = useDerivedValue<number[]>(() => { const avoidScalingOutputRange = [1, 1, 1, 1, 1]; const showScalingOutputRange = [0.5, 0.9, 1.2, 0.9, 0.5]; const finalOutputRange = scaleEnabled ? showScalingOutputRange : avoidScalingOutputRange; const scaledOutput = withSpring<number[]>(finalOutputRange); return scaledOutput; }, [scaleEnabled]); const scale = useDerivedValue<number>(() => { const interpolatedScale = interpolate( contentOffset.value, inputRange, scaleOutputRange.value, Extrapolation.CLAMP, ); return interpolatedScale; }, [scaleEnabled]); const blurIntensity = useDerivedValue<number>(() => { const blurOutputRange = [80, 40, 0, 40, 80]; const interpolatedBlur = interpolate( contentOffset.value, inputRange, blurOutputRange, Extrapolation.CLAMP, ); return interpolatedBlur; }, []); const rStyle = useAnimatedStyle< Partial<Required<Pick<ViewStyle, "opacity" | "transform">>> >(() => { const translateOutputRange = [ 0, -LIST_ITEM_WIDTH / 3, -LIST_ITEM_WIDTH / 2, -LIST_ITEM_WIDTH / 3, 0, ]; const opacityOutputRange = [0.5, 1, 1, 1, 0.5]; const translateY = interpolate( contentOffset.value, inputRange, translateOutputRange, ); const opacity = interpolate( contentOffset.value, inputRange, opacityOutputRange, Extrapolation.CLAMP, ); return { opacity, transform: [ { translateX: LIST_ITEM_WIDTH / 2 + LIST_ITEM_WIDTH, }, { translateY, }, { scale: scale.value }, ], }; }, []); const blurStyle = useAnimatedStyle< Partial<Pick<ViewStyle, "opacity">> >(() => { return { opacity: interpolate( blurIntensity.value, [0, 50], [0, 1], Extrapolation.CLAMP, ), }; }, []); return ( <Animated.View style={[styles.container, rStyle]}> <View style={styles.imageContainer}> <Image style={styles.image} source={{ uri: imageUri }} /> <Animated.View style={[StyleSheet.absoluteFill, blurStyle]}> <BlurView intensity={blurIntensity.value} style={styles.blurView} tint="dark" /> </Animated.View> </View> </Animated.View> ); },);const CircularList: FC<ICircularList> & FunctionComponent<ICircularList> = memo<ICircularList>( ({ data, scaleEnabled, }: ICircularList): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const contentOffset = useSharedValue<number>(0); const previousIndex = useSharedValue<number>(-1); const triggerHaptic = () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }; useDerivedValue<void>(() => { const currentIndex = Math.round(contentOffset.value / LIST_ITEM_WIDTH); if ( currentIndex !== previousIndex.value && previousIndex.value !== -1 ) { scheduleOnRN<[], void>(triggerHaptic); } previousIndex.value = currentIndex; }, []); const onScroll = useAnimatedScrollHandler<Record<string, unknown>>({ onScroll: (event) => { contentOffset.value = event.contentOffset.x; }, }); return ( <Animated.FlatList<string> snapToInterval={LIST_ITEM_WIDTH} showsHorizontalScrollIndicator={false} style={styles.list} pagingEnabled contentContainerStyle={styles.listContent} horizontal data={data} scrollEventThrottle={16} onScroll={onScroll} renderItem={({ index }) => ( <CircularListItem imageUri={data[index]} scaleEnabled={scaleEnabled} index={index} contentOffset={contentOffset} /> )} keyExtractor={(_, index) => index.toString()} /> ); }, );const styles = StyleSheet.create({ container: { aspectRatio: 1, width: LIST_ITEM_WIDTH, }, image: { borderCurve: "continuous", borderRadius: 100, borderWidth: 2, flex: 1, }, imageContainer: { borderCurve: "continuous", borderRadius: 100, boxShadow: "0px 0px 10px rgba(0, 0, 0, 0.1)", flex: 1, margin: 8, overflow: "hidden", }, blurView: { flex: 1, borderRadius: 100, }, list: { bottom: 0, height: LIST_ITEM_WIDTH * 3, left: 0, position: "absolute", right: 0, }, listContent: { alignItems: "center", justifyContent: "center", paddingRight: LIST_ITEM_WIDTH * 3, },});export { CircularList };export default CircularList;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 { SymbolView } from "expo-symbols";import CircularList from "@/components/molecules/circular-list";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"), }); const CONTACTS = [ "https://i.pinimg.com/736x/7e/91/43/7e91431a19f19426f94418cd4ee15548.jpg", "https://i.pinimg.com/1200x/02/8e/12/028e12754cbefa35a1ccbbbb69523ed7.jpg", "https://i.pinimg.com/736x/f2/c7/38/f2c7384598be1f5a2126e7b946e16a24.jpg", "https://i.pinimg.com/736x/e5/80/d5/e580d5227f399828d0adaa7eef232482.jpg", "https://i.pinimg.com/736x/c2/8f/94/c28f94a21c8dfebc3f2b6ba608c8099b.jpg", "https://i.pinimg.com/736x/c4/40/a0/c440a0df8d75affe4a5df5d7979359b0.jpg", "https://i.pinimg.com/736x/9c/ad/87/9cad87372cb22bb5a46260be66a3741e.jpg", ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.header}> <View style={styles.backButton}> <SymbolView name="chevron.left" size={14} tintColor="#fff" /> </View> <Text style={[styles.title, fontLoaded && { fontFamily: "SfProRounded" }]} > Quick Call </Text> <View style={styles.placeholder} /> </View> <View style={styles.listContainer}> <CircularList data={CONTACTS} scaleEnabled={Boolean(true)} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 20, paddingTop: 70, }, backButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", }, title: { fontSize: 17, fontWeight: "600", color: "#fff", }, placeholder: { width: 40, }, listContainer: { height: SCREEN_WIDTH * 0.6, marginTop: 40, }, actions: { alignItems: "center", gap: 24, paddingHorizontal: 24, }, actionRow: { flexDirection: "row", alignItems: "center", gap: 20, }, actionButton: { width: 56, height: 56, borderRadius: 28, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", }, callButton: { width: 72, height: 72, borderRadius: 36, backgroundColor: "#30d158", }, hint: { fontSize: 13, color: "#444", },});Props
CircularCarouselItemProps
React Native Reanimated
React Native Worklets
Expo Blur
Expo Haptics
