Radial Intro

An animated radial intro inspired by react-bits

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-blur

Copy and paste the following code into your project. component/organisms/radial-intro

/** * RadialIntro - Orbiting images animation component * Inspired by animate-ui radial-intro component (https://animate-ui.com/docs/components/community/radial-intro) */import React, { useEffect, memo, useCallback } from "react";import { View, StyleSheet, Pressable, type ViewStyle } from "react-native";import Animated, {  useSharedValue,  useAnimatedStyle,  useAnimatedProps,  withTiming,  withDelay,  withRepeat,  Easing,  cancelAnimation,  type SharedValue,} from "react-native-reanimated";import { BlurView } from "expo-blur";import type { OrbitItem, OrbitArmProps, RadialIntroProps } from "./types";import { ANIMATION_DELAYS, TIMING_CONFIG, TIMING_CONFIG_SLOW } from "./config";const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);const OrbitArm = memo<OrbitArmProps>(  ({    item,    index,    totalItems,    stageSize,    imageSize,    spinDuration,    orbitRadius,    expanded,    isCenter,    revealOnFanOut,    onCenterPress,  }): React.ReactElement => {    const step: number = 360 / totalItems;    const targetAngle: number = index * step;    const staggerDelay: number = index * 40;    const armRotation: SharedValue<number> = useSharedValue<number>(0);    const imageOffsetY: SharedValue<number> =      useSharedValue<number>(orbitRadius);    const imageRotation: SharedValue<number> = useSharedValue<number>(0);    const imageOpacity: SharedValue<number> = useSharedValue<number>(      isCenter ? 1 : 0,    );    const blurAmount: SharedValue<number> = useSharedValue<number>(0);    const startContinuousSpin = useCallback((): void => {      armRotation.value = withRepeat(        withTiming(targetAngle + 360, {          duration: spinDuration * 1000,          easing: Easing.linear,        }),        -1,        false,      );      imageRotation.value = withRepeat(        withTiming(-targetAngle - 360, {          duration: spinDuration * 1000,          easing: Easing.linear,        }),        -1,        false,      );    }, [targetAngle, spinDuration, armRotation, imageRotation]);    const collapseOrbit = useCallback((): void => {      cancelAnimation<number>(armRotation);      cancelAnimation<number>(imageRotation);      const reverseStagger: number = (totalItems - 1 - index) * 30;      blurAmount.value = withDelay(        reverseStagger,        withTiming(25, { duration: 150, easing: Easing.in(Easing.cubic) }),      );      imageOffsetY.value = withDelay(        reverseStagger,        withTiming(orbitRadius, TIMING_CONFIG),      );      armRotation.value = withDelay(        reverseStagger,        withTiming(0, TIMING_CONFIG),      );      imageRotation.value = withDelay(        reverseStagger,        withTiming(0, TIMING_CONFIG),      );      if (!isCenter) {        imageOpacity.value = withDelay(          reverseStagger + 200,          withTiming(0, { duration: 300, easing: Easing.out(Easing.cubic) }),        );      }      if (isCenter) {        blurAmount.value = withDelay(          reverseStagger + 300,          withTiming(0, { duration: 900, easing: Easing.out(Easing.cubic) }),        );      }    }, [      orbitRadius,      isCenter,      index,      totalItems,      imageOffsetY,      armRotation,      imageRotation,      imageOpacity,      blurAmount,    ]);    useEffect((): (() => void) | void => {      if (expanded) {        const revealDelay: number = revealOnFanOut          ? ANIMATION_DELAYS.ORBIT_PLACEMENT + staggerDelay          : ANIMATION_DELAYS.IMAGE_LIFT + staggerDelay;        blurAmount.value = withDelay(          revealDelay,          withTiming(20, { duration: 50, easing: Easing.linear }),        );        imageOffsetY.value = withDelay(          ANIMATION_DELAYS.IMAGE_LIFT + staggerDelay,          withTiming(0, TIMING_CONFIG),        );        imageOpacity.value = withDelay(          revealDelay,          withTiming(1, { duration: 200, easing: Easing.out(Easing.cubic) }),        );        armRotation.value = withDelay(          ANIMATION_DELAYS.ORBIT_PLACEMENT + staggerDelay,          withTiming(targetAngle, TIMING_CONFIG_SLOW),        );        imageRotation.value = withDelay(          ANIMATION_DELAYS.ORBIT_PLACEMENT + staggerDelay,          withTiming(-targetAngle, TIMING_CONFIG_SLOW),        );        blurAmount.value = withDelay(          ANIMATION_DELAYS.ORBIT_PLACEMENT + staggerDelay + 200,          withTiming(0, { duration: 400, easing: Easing.out(Easing.cubic) }),        );        const spinTimeout = setTimeout(          () => {            startContinuousSpin();          },          ANIMATION_DELAYS.CONTINUOUS_SPIN + totalItems * 40,        );        return (): void => {          clearTimeout(spinTimeout);        };      } else {        collapseOrbit();      }    }, [      expanded,      targetAngle,      staggerDelay,      totalItems,      revealOnFanOut,      armRotation,      imageOffsetY,      imageRotation,      imageOpacity,      blurAmount,      startContinuousSpin,      collapseOrbit,    ]);    const armAnimatedStyle = useAnimatedStyle(      (): ViewStyle => ({        transform: [{ rotate: `${armRotation.value}deg` }],      }),    );    const imageAnimatedStyle = useAnimatedStyle(      (): ViewStyle => ({        opacity: imageOpacity.value,        transform: [          { translateX: -imageSize / 2 },          { translateY: imageOffsetY.value },          { rotate: `${imageRotation.value}deg` },        ],      }),    );    const animatedBlurProps = useAnimatedProps(() => ({      intensity: blurAmount.value,    }));    const imageContent: React.ReactElement = (      <View        style={[          styles.imageWrapper,          {            width: imageSize,            height: imageSize,            borderRadius: imageSize / 2,          },        ]}      >        <Animated.Image          source={{ uri: item.src }}          style={[            {              width: imageSize,              height: imageSize,              borderRadius: imageSize / 2,            },          ]}          resizeMode="cover"        />        <AnimatedBlurView          style={[            StyleSheet.absoluteFillObject,            styles.blurOverlay,            { borderRadius: imageSize / 2 },          ]}          tint="prominent"          animatedProps={animatedBlurProps}        />      </View>    );    return (      <Animated.View        style={[          styles.arm,          {            width: stageSize,            height: stageSize,            zIndex: totalItems - index,          },          armAnimatedStyle,        ]}      >        <Animated.View          style={[            styles.imageContainer,            {              left: stageSize / 2,              top: stageSize / 2 - orbitRadius,            },            imageAnimatedStyle,          ]}        >          {isCenter && onCenterPress ? (            <Pressable onPress={onCenterPress}>{imageContent}</Pressable>          ) : (            imageContent          )}        </Animated.View>      </Animated.View>    );  },);OrbitArm.displayName = "RadialIntro.OrbitArm";const RadialIntro = memo<RadialIntroProps>(  ({    orbitItems,    stageSize = 320,    imageSize = 60,    spinDuration = 30,    expanded = false,    onCenterPress,    revealOnFanOut = true,    style,  }): React.ReactElement => {    const orbitRadius: number = stageSize / 2 - imageSize / 2;    return (      <View        style={[          styles.container,          {            width: stageSize,            height: stageSize,          },          style,        ]}      >        {orbitItems.map(          (item: OrbitItem, index: number): React.ReactElement => (            <OrbitArm              key={item.id}              item={item}              index={index}              totalItems={orbitItems.length}              stageSize={stageSize}              imageSize={imageSize}              spinDuration={spinDuration}              orbitRadius={orbitRadius}              expanded={expanded}              isCenter={index === 0}              revealOnFanOut={revealOnFanOut}              onCenterPress={onCenterPress}            />          ),        )}      </View>    );  },);const styles = StyleSheet.create({  container: {    position: "relative",    overflow: "visible",  },  arm: {    position: "absolute",    top: 0,    left: 0,  },  imageContainer: {    position: "absolute",  },  imageWrapper: {    overflow: "hidden",  },  blurOverlay: {    overflow: "hidden",  },});export { RadialIntro, type RadialIntroProps, type OrbitItem };

Usage

import { StyleSheet, Text, View } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useState } from "react";import { RadialIntro, OrbitItem } from "@/components/organisms/radial-intro";const ORBIT_ITEMS: OrbitItem[] = [  {    id: 1,    src: "https://i.pinimg.com/736x/87/c7/15/87c715543490556e23d672dd6a027ab6.jpg",  },  {    id: 2,    src: "https://i.pinimg.com/736x/7b/d6/4c/7bd64c93eb86c96ce99edb72fbfc1ff5.jpg",  },  {    id: 3,    src: "https://i.pinimg.com/736x/87/f0/07/87f0079fbcf63f332973c05974416042.jpg",  },  {    id: 4,    src: "https://i.pinimg.com/736x/fd/18/49/fd184979b399f45ef804ae9dbf087f49.jpg",  },  {    id: 5,    src: "https://i.pinimg.com/736x/f9/7f/85/f97f85fdffc8d0981d3fc9013da5ce04.jpg",  },];export default function App() {  const [expanded, setExpanded] = useState(false);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.card}>        <RadialIntro          orbitItems={ORBIT_ITEMS}          expanded={expanded}          stageSize={500}          style={{ marginTop: 10 }}          revealOnFanOut={Boolean(false)}          imageSize={90}          spinDuration={12}          onCenterPress={() => setExpanded((v) => !v)}        />      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",    alignItems: "center",  },  card: {},  caption: {    marginTop: 18,    fontSize: 13,    color: "rgba(255,255,255,0.55)",    letterSpacing: -0.2,  },});

Props

React Native Reanimated
Expo Blur