Unstable Infinite Menu

A Global, Infinite Radial Menu built with React Native Reanimated, Skia Shaders, and React Native Gesture Handler

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install @shopify/react-native-skia react-native-reanimated react-native-gesture-handler react-native-worklets

Copy and paste the following code into your project. component/organisms/unstable_infinite-menu.tsx

// @ts-checkimport React, { useCallback, useMemo, useState, memo, useEffect } from "react";import { View, StyleSheet, Animated as RNAnimated } from "react-native";import {  Canvas,  Circle,  Group,  Image,  Skia,  Fill,  Shader,  ImageShader,} from "@shopify/react-native-skia";import { useSharedValue, useFrameCallback } from "react-native-reanimated";import {  Gesture,  GestureDetector,  GestureHandlerRootView,} from "react-native-gesture-handler";import { useLoadImages } from "./hooks";import type { IQuat, IVec3 } from "./maths-type";import type {  IDisc,  IDiscComponent,  IMenuItem,  IUnstableInfiniteMenu,} from "./types";import { generateIcosahedronVertices } from "./helpers";import {  projectToSphere,  quatConjugate,  quatFromVectors,  quatMultiply,  quatNormalize,  quatRotateVec3,  quatSlerp,  vec3Normalize,  vec3Cross,} from "./maths";import {  screenWidth,  screenHeight,  SPHERE_RADIUS_BASE,  DISC_BASE_SCALE,  CAMERA_Z_BASE,  PROJECTION_SCALE,} from "./const";import { discShader } from "./conf";import { scheduleOnRN } from "react-native-worklets";const DiscComponent: React.FC<IDiscComponent> = memo<IDiscComponent>(  ({    x,    y,    radius,    alpha,    stretchAmount,    stretchAngle,    image,  }: IDiscComponent):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    if (radius < 1) return null;    const stretchScale = 1 + stretchAmount;    const shrinkScale = 1 / stretchScale;    if (!image) {      return (        <Group          transform={[            { translateX: x },            { translateY: y },            { rotate: stretchAngle },            { scaleX: stretchScale },            { scaleY: shrinkScale },            { rotate: -stretchAngle },            { translateX: -x },            { translateY: -y },          ]}          opacity={alpha}        >          <Circle cx={x} cy={y} r={radius} color="rgba(80, 80, 80, 1)" />        </Group>      );    }    const clipPath = Skia.Path.Make();    clipPath.addCircle(x, y, radius);    if (!discShader) {      return (        <Group          transform={[            { translateX: x },            { translateY: y },            { rotate: stretchAngle },            { scaleX: stretchScale },            { scaleY: shrinkScale },            { rotate: -stretchAngle },            { translateX: -x },            { translateY: -y },          ]}          opacity={alpha}        >          <Group clip={clipPath}>            <Image              image={image}              x={x - radius}              y={y - radius}              width={radius * 2}              height={radius * 2}              fit="cover"            />          </Group>        </Group>      );    }    const stretchDirX = Math.cos(stretchAngle);    const stretchDirY = Math.sin(stretchAngle);    const uniforms = {      resolution: [radius * 2, radius * 2],      alpha: alpha,      stretchAmount: stretchAmount,      stretchDir: [stretchDirX, stretchDirY],    };    return (      <Group        transform={[          { translateX: x },          { translateY: y },          { rotate: stretchAngle },          { scaleX: stretchScale },          { scaleY: shrinkScale },          { rotate: -stretchAngle },          { translateX: -x },          { translateY: -y },        ]}      >        <Group clip={clipPath}>          <Group            transform={[{ translateX: x - radius }, { translateY: y - radius }]}          >            <Fill>              <Shader source={discShader} uniforms={uniforms}>                <ImageShader                  image={image}                  fit="cover"                  x={0}                  y={0}                  width={radius * 2}                  height={radius * 2}                />              </Shader>            </Fill>          </Group>        </Group>      </Group>    );  },);export const UnstableInfiniteMenu: React.FC<IUnstableInfiniteMenu> &  React.FunctionComponent<IUnstableInfiniteMenu> = memo<IUnstableInfiniteMenu>(  ({    items,    scale = 1,    stretchIntensity = 15,    friction = 0.1,    snapStrength = 0.2,    discSize = 1,    backgroundColor = "black",  }: IUnstableInfiniteMenu):    | (React.ReactElement & React.ReactNode & React.JSX.Element)    | null => {    const centerX = screenWidth / 2;    const centerY = screenHeight / 2;    const imageUrls = useMemo(      () => items.map<string>((item) => item.image),      [items],    );    const loadedImages = useLoadImages<string[]>(imageUrls);    const [activeItem, setActiveItem] = useState<IMenuItem | null>(      items[0] || null,    );    const [isMoving, setIsMoving] = useState<boolean>(false);    const [discData, setDiscData] = useState<IDisc[]>([]);    const SPHERE_RADIUS = SPHERE_RADIUS_BASE * scale;    const CAMERA_Z = CAMERA_Z_BASE * scale;    const sphereVertices = useMemo(      () => generateIcosahedronVertices(1, SPHERE_RADIUS),      [SPHERE_RADIUS],    );    const verticesRef = useMemo(() => [...sphereVertices], [sphereVertices]);    const qx = useSharedValue<number>(0);    const qy = useSharedValue<number>(0);    const qz = useSharedValue<number>(0);    const qw = useSharedValue<number>(1);    const prx = useSharedValue<number>(0);    const pry = useSharedValue<number>(0);    const prz = useSharedValue<number>(0);    const prw = useSharedValue<number>(1);    const rotVelocity = useSharedValue<number>(0);    const isDown = useSharedValue<boolean>(false);    const prevX = useSharedValue<number>(0);    const prevY = useSharedValue<number>(0);    const camZ = useSharedValue<number>(CAMERA_Z);    const activeIdx = useSharedValue<number>(0);    const rotAxisX = useSharedValue<number>(1);    const rotAxisY = useSharedValue<number>(0);    const rotAxisZ = useSharedValue<number>(0);    const smoothRotVelocity = useSharedValue<number>(0);    const fadeAnim = useMemo(() => new RNAnimated.Value(1), []);    const scaleAnim = useMemo(() => new RNAnimated.Value(1), []);    const updateActiveItem = useCallback(      (index: number) => {        if (items.length === 0) return;        const itemIndex = index % items.length;        setActiveItem(items[itemIndex]);      },      [items],    );    const updateIsMoving = useCallback((moving: boolean) => {      setIsMoving(moving);    }, []);    const updateDiscData = useCallback((data: IDisc[]) => {      setDiscData(data);    }, []);    useFrameCallback((info) => {      "worklet";      const dt = info.timeSincePreviousFrame || 16;      const ts = dt / 16 + 0.0001;      const IDENTITY: IQuat = { x: 0, y: 0, z: 0, w: 1 };      const orientation: IQuat = {        x: qx.value,        y: qy.value,        z: qz.value,        w: qw.value,      };      const pointerRot: IQuat = {        x: prx.value,        y: pry.value,        z: prz.value,        w: prw.value,      };      const dampIntensity = friction * ts;      const dampenedPR = quatSlerp(pointerRot, IDENTITY, dampIntensity);      prx.value = dampenedPR.x;      pry.value = dampenedPR.y;      prz.value = dampenedPR.z;      prw.value = dampenedPR.w;      let snapRot: IQuat = IDENTITY;      if (!isDown.value) {        const snapDir: IVec3 = { x: 0, y: 0, z: -1 };        const invOrientation = quatConjugate(orientation);        const transformedSnapDir = quatRotateVec3(invOrientation, snapDir);        let maxDot = -Infinity;        let nearestIdx = 0;        for (let i = 0; i < verticesRef.length; i++) {          const v = verticesRef[i];          const dot =            transformedSnapDir.x * v.x +            transformedSnapDir.y * v.y +            transformedSnapDir.z * v.z;          if (dot > maxDot) {            maxDot = dot;            nearestIdx = i;          }        }        const nearestV = verticesRef[nearestIdx];        const worldV = quatRotateVec3(orientation, nearestV);        const targetDir = vec3Normalize(worldV);        const sqrDist =          (targetDir.x - snapDir.x) ** 2 +          (targetDir.y - snapDir.y) ** 2 +          (targetDir.z - snapDir.z) ** 2;        const distFactor = Math.max(0.1, 1 - sqrDist * 10);        const snapIntensity = snapStrength * ts * distFactor;        snapRot = quatFromVectors(targetDir, snapDir, snapIntensity);        const itemLen = Math.max(1, items.length);        const itemIdx = nearestIdx % itemLen;        if (activeIdx.value !== itemIdx) {          activeIdx.value = itemIdx;          scheduleOnRN(updateActiveItem, itemIdx);        }      }      const combined = quatMultiply(snapRot, dampenedPR);      const newOrientation = quatNormalize(quatMultiply(combined, orientation));      qx.value = newOrientation.x;      qy.value = newOrientation.y;      qz.value = newOrientation.z;      qw.value = newOrientation.w;      const rad = Math.acos(Math.min(1, Math.max(-1, combined.w))) * 2;      const s = Math.sin(rad / 2);      let rv = 0;      if (s > 0.000001) {        rv = rad / (2 * Math.PI);        rotAxisX.value = combined.x / s;        rotAxisY.value = combined.y / s;        rotAxisZ.value = combined.z / s;      }      const RV_INTENSITY = 0.5 * ts;      smoothRotVelocity.value += (rv - smoothRotVelocity.value) * RV_INTENSITY;      rotVelocity.value = smoothRotVelocity.value / ts;      const targetZ = isDown.value        ? CAMERA_Z + rotVelocity.value * 80 + 2.5        : CAMERA_Z;      const damping = isDown.value ? 7 / ts : 5 / ts;      camZ.value += (targetZ - camZ.value) / damping;      const moving = isDown.value || Math.abs(smoothRotVelocity.value) > 0.01;      scheduleOnRN(updateIsMoving, moving);      const discs: IDisc[] = [];      const currentCamZ = camZ.value;      const itemLen = Math.max(1, items.length);      const currentRotVel = Math.min(        0.15,        smoothRotVelocity.value * stretchIntensity,      );      const rotAxis: IVec3 = {        x: rotAxisX.value,        y: rotAxisY.value,        z: rotAxisZ.value,      };      for (let i = 0; i < verticesRef.length; i++) {        const v = verticesRef[i];        const worldPos = quatRotateVec3(newOrientation, v);        const stretchDir = vec3Cross(worldPos, rotAxis);        const stretchLen = Math.sqrt(          stretchDir.x * stretchDir.x +            stretchDir.y * stretchDir.y +            stretchDir.z * stretchDir.z,        );        let stretchAngle = 0;        if (stretchLen > 0.001) {          const normStretchDir = {            x: stretchDir.x / stretchLen,            y: stretchDir.y / stretchLen,            z: stretchDir.z / stretchLen,          };          stretchAngle = Math.atan2(normStretchDir.y, normStretchDir.x);        }        const perspective = currentCamZ / (currentCamZ - worldPos.z);        const sx = centerX + worldPos.x * perspective * PROJECTION_SCALE;        const sy = centerY - worldPos.y * perspective * PROJECTION_SCALE;        const zFactor = (Math.abs(worldPos.z) / SPHERE_RADIUS) * 0.6 + 0.4;        const baseRadius =          zFactor * DISC_BASE_SCALE * discSize * perspective * PROJECTION_SCALE;        const normalizedZ = worldPos.z / SPHERE_RADIUS;        const alpha = (normalizedZ * 0.5 + 0.5) * 0.9 + 0.1;        discs.push({          screenX: sx,          screenY: sy,          radius: baseRadius,          alpha: Math.max(0.1, Math.min(1, alpha)),          z: worldPos.z,          itemIndex: i % itemLen,          stretchAmount: currentRotVel,          stretchAngle: stretchAngle,        });      }      discs.sort((a, b) => a.z - b.z);      scheduleOnRN(updateDiscData, discs);    });    const panGesture = Gesture.Pan()      .minDistance(1)      .onStart((e) => {        "worklet";        prevX.value = e.x;        prevY.value = e.y;        isDown.value = true;      })      .onUpdate((e) => {        "worklet";        const intensity = 0.3;        const amplification = 5;        const midX = prevX.value + (e.x - prevX.value) * intensity;        const midY = prevY.value + (e.y - prevY.value) * intensity;        const dx = midX - prevX.value;        const dy = midY - prevY.value;        if (dx * dx + dy * dy > 0.1) {          const p = projectToSphere(midX, midY);          const q = projectToSphere(prevX.value, prevY.value);          const newRot = quatFromVectors(p, q, amplification);          prx.value = newRot.x;          pry.value = newRot.y;          prz.value = newRot.z;          prw.value = newRot.w;          prevX.value = midX;          prevY.value = midY;        }      })      .onEnd(() => {        "worklet";        isDown.value = false;      })      .onFinalize(() => {        "worklet";        isDown.value = false;      });    useEffect(() => {      RNAnimated.parallel([        RNAnimated.timing(fadeAnim, {          toValue: isMoving ? 0 : 1,          duration: isMoving ? 100 : 500,          useNativeDriver: true,        }),        RNAnimated.timing(scaleAnim, {          toValue: isMoving ? 0 : 1,          duration: isMoving ? 100 : 500,          useNativeDriver: true,        }),      ]).start();    }, [isMoving, fadeAnim, scaleAnim]);    return (      <GestureHandlerRootView style={styles.container}>        <View style={styles.container}>          <GestureDetector gesture={panGesture}>            <Canvas style={[styles.canvas, { backgroundColor }]}>              <Fill color={backgroundColor} />              {discData.map((disc, idx) => (                <DiscComponent                  key={`disc-${idx}`}                  x={disc.screenX}                  y={disc.screenY}                  radius={disc.radius}                  alpha={disc.alpha}                  stretchAmount={disc.stretchAmount}                  stretchAngle={disc.stretchAngle}                  image={loadedImages[disc.itemIndex] || null}                />              ))}            </Canvas>          </GestureDetector>        </View>      </GestureHandlerRootView>    );  },);const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000000",  },  canvas: {    flex: 1,    backgroundColor: "#000000",  },  titleContainer: {    position: "absolute",    left: 24,    top: "50%",    marginTop: -30,  },  title: {    fontSize: 36,    fontWeight: "900",    color: "#fff",  },  descriptionContainer: {    position: "absolute",    right: 24,    top: "50%",    marginTop: -30,    maxWidth: 120,  },  description: {    fontSize: 14,    color: "#fff",    textAlign: "right",  },  actionButtonContainer: {    position: "absolute",    bottom: 50,    left: "50%",    marginLeft: -30,  },  actionButton: {    width: 60,    height: 60,    borderRadius: 30,    backgroundColor: "#00ffff",    borderWidth: 4,    borderColor: "#000",    justifyContent: "center",    alignItems: "center",  },  actionButtonText: {    fontSize: 24,    color: "#060010",    fontWeight: "bold",  },});export default UnstableInfiniteMenu;export type {  IDisc,  IDiscComponent,  IMenuItem,  IUnstableInfiniteMenu,} from "./types";

Usage

import { View, StyleSheet, StatusBar } from "react-native";import React, { type ReactElement } from "react";import UnstableInfiniteMenu, {  type IUnstableInfiniteMenu,} from "@/components/organisms/unstable_infinite-menu";const MENU_ITEMS: IUnstableInfiniteMenu[] = [  {    image:      "https://i.pinimg.com/736x/22/1e/ae/221eae1af669db2d93cc2155c74371ff.jpg",  },  {    image:      "https://i.pinimg.com/736x/2c/d9/66/2cd96620a3a595e3e80e5ddf364fa162.jpg",  },  {    image:      "https://i.pinimg.com/736x/83/49/c2/8349c22cc5c73a6eddbf561a41c09fda.jpg",  },  {    image:      "https://i.pinimg.com/736x/08/0f/3c/080f3c1e3b8d4a4c020e72ed8ebe982b.jpg",  },];export default function App<T extends React.FC>(): React.JSX.Element &  ReactElement {  return (    <>      <StatusBar barStyle="dark-content" backgroundColor="#000" />      <View style={styles.container}>        <IUnstableInfiniteMenu          items={MENU_ITEMS}          style={styles.menuContainer}        />      </View>    </>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",  },  menuContainer: {    width: "100%",    height: "100%",  },});

Props

IMenuItem

React Native Reanimated
React Native Skia
React Native Gesture Handler