Avatar Group

An interactive group of overlapping avatars.

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-worklets expo-haptics react-native-gesture-handler

Copy and paste the following code into your project. component/base/avatar-group.tsx

import React, { memo, useCallback } from "react";import { View, Image, Text, StyleSheet, type ViewStyle } from "react-native";import Animated, {  useAnimatedStyle,  useSharedValue,  withTiming,} from "react-native-reanimated";import { Gesture, GestureDetector } from "react-native-gesture-handler";import * as Haptics from "expo-haptics";import type { AvatarGroupProps, IAnimatedAvatar } from "./types";import { scheduleOnRN } from "react-native-worklets";import { getColorFromString } from "./helper";import { LIFT_DISTANCE, SCALE_UP } from "./const";const AnimatedAvatar: React.FC<IAnimatedAvatar> &  React.FunctionComponent<IAnimatedAvatar> = memo<IAnimatedAvatar>(  ({    avatar,    size,    overlap,    index,    activeIndex,  }: IAnimatedAvatar):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const animatedStylez = useAnimatedStyle<      Pick<ViewStyle, "transform" | "zIndex">    >(() => {      const isActive = activeIndex.value === index;      return {        transform: [          {            translateY: withTiming<number>(isActive ? LIFT_DISTANCE : 0, {              duration: 200,            }),          },          {            scale: withTiming<number>(isActive ? SCALE_UP : 1, {              duration: 200,            }),          },        ],        zIndex: isActive ? 1000 : index,      };    });    const contentOpacity = useAnimatedStyle<Pick<ViewStyle, "opacity">>(() => {      const isActive = activeIndex.value === index;      return {        opacity: withTiming(isActive ? 0 : 1, {          duration: 150,        }),      };    });    const nameOpacity = useAnimatedStyle<Pick<ViewStyle, "opacity">>(() => {      const isActive = activeIndex.value === index;      return {        opacity: withTiming(isActive ? 1 : 0, {          duration: 150,        }),      };    });    return (      <Animated.View        style={[          {            marginLeft: index === 0 ? 0 : -overlap,            width: size,            height: size,          },          animatedStylez,        ]}      >        <Animated.View style={contentOpacity}>          {avatar.uri ? (            <Image              source={{ uri: avatar.uri }}              style={[                styles.avatar,                { width: size, height: size, borderRadius: size / 2 },              ]}            />          ) : (            <View              style={[                styles.fallback,                { width: size, height: size, borderRadius: size / 2 },              ]}            >              <Text style={[styles.fallbackText, { fontSize: size / 2 }]}>                {avatar.name?.charAt(0).toUpperCase()}              </Text>            </View>          )}        </Animated.View>        <Animated.View          style={[            styles.nameContainer,            {              width: size,              height: size,              borderRadius: size / 2,              backgroundColor: getColorFromString(avatar.name || avatar.id),            },            nameOpacity,          ]}        >          <Text            style={[styles.nameText, { fontSize: size / 4 }]}            numberOfLines={1}            adjustsFontSizeToFit          >            {avatar.name || avatar.id}          </Text>        </Animated.View>      </Animated.View>    );  },);export const AvatarGroup: React.FC<AvatarGroupProps> &  React.FunctionComponent<AvatarGroupProps> = memo<AvatarGroupProps>(  ({    avatars,    size = 40,    max = 5,    overlap = 10,    onPress,  }: AvatarGroupProps):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const displayed = avatars.slice(0, max);    const extraCount = avatars.length - max;    const activeIndex = useSharedValue<number>(-1);    const lastHapticIndex = useSharedValue<number>(-1);    const triggerHaptic = useCallback(() => {      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);    }, []);    const calculateActiveIndex = useCallback(      <T extends number>(x: T) => {        "worklet";        for (let i = 0; i < displayed.length; i++) {          const avatarStart = i * (size - overlap);          const avatarEnd = avatarStart + size;          if (x >= avatarStart && x <= avatarEnd) {            return i;          }        }        if (x > displayed.length * (size - overlap)) {          return displayed.length - 1;        }        return 0;      },      [size, overlap, displayed.length],    );    const panGesture = Gesture.Pan()      .minDistance(0)      .onBegin((event) => {        "worklet";        const index = calculateActiveIndex<number>(event.x);        activeIndex.value = index;        lastHapticIndex.value = index;        scheduleOnRN(triggerHaptic);      })      .onUpdate((event) => {        "worklet";        const index = calculateActiveIndex<number>(event.x);        if (index !== activeIndex.value) {          activeIndex.value = index;          if (index !== lastHapticIndex.value) {            lastHapticIndex.value = index;            scheduleOnRN<[], void>(triggerHaptic);          }        }      })      .onEnd(() => {        "worklet";        activeIndex.value = -1;        lastHapticIndex.value = -1;      })      .onFinalize(() => {        "worklet";        activeIndex.value = -1;        lastHapticIndex.value = -1;      })      .onTouchesUp(() => {        "worklet";        activeIndex.value = -1;        lastHapticIndex.value = -1;      });    const tapGesture = Gesture.Tap()      .maxDuration(200)      .onEnd((event) => {        if (onPress) {          const index = calculateActiveIndex<number>(event.x);          if (index >= 0 && index < displayed.length) {            scheduleOnRN(onPress, displayed[index].id);          }        }      });    const composedGesture = Gesture.Exclusive(panGesture, tapGesture);    return (      <GestureDetector gesture={composedGesture}>        <View style={styles.container}>          {displayed.map<React.ReactNode>((avatar, idx: number) => (            <AnimatedAvatar              key={avatar.id}              avatar={avatar}              size={size}              overlap={overlap}              index={idx}              activeIndex={activeIndex}            />          ))}          {extraCount > 0 && (            <View              style={[                styles.extra,                {                  width: size,                  height: size,                  borderRadius: size / 2,                  marginLeft: -overlap,                },              ]}            >              <Text style={[styles.extraText, { fontSize: size / 2 }]}>                +{extraCount}              </Text>            </View>          )}        </View>      </GestureDetector>    );  },);export default memo<  React.FC<AvatarGroupProps> & React.FunctionComponent<AvatarGroupProps>>(AvatarGroup);const styles = StyleSheet.create({  container: {    flexDirection: "row",    alignItems: "center",  },  avatar: {    borderWidth: 2,    borderColor: "#fff",  },  fallback: {    backgroundColor: "#ccc",    justifyContent: "center",    alignItems: "center",    borderWidth: 2,    borderColor: "#fff",  },  fallbackText: {    color: "#fff",    fontWeight: "600",  },  nameContainer: {    justifyContent: "center",    alignItems: "center",    position: "absolute",    top: 0,    left: 0,    paddingHorizontal: 4,  },  nameText: {    color: "#fff",    fontWeight: "700",    textAlign: "center",  },  extra: {    backgroundColor: "#888",    justifyContent: "center",    alignItems: "center",    borderWidth: 2,    borderColor: "#fff",  },  extraText: {    color: "#fff",    fontWeight: "600",  },});

Usage

import { StyleSheet, Text, View } from "react-native";import React from "react";import { AvatarGroup } from "@/components/base/avatar-group";const App = () => {  return (    <AvatarGroup      avatars={[        {          id: "1",          name: "Name",          uri: "example-uri",        },      ]}    />  );};export default App;const styles = StyleSheet.create({});

Props

IAnimatedAvatar

AvatarItem

React Native Reanimated
React Native Gesture Handler
React Native Worklets
Expo Haptics