Circular List

A circular scrolling image list

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

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

Copy 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