Skip to content
Native Animation

React Native Circle Menu with an Animated Radial Fan

By Native Animation · Updated

A React Native circle menu reveals a small set of actions around a central trigger. This tutorial builds a semicircular fan with measured radius, one shared opening value, and a list fallback when the available width cannot fit separate touch targets. The result is an action menu with buttons, not a progress ring or a circular loading animation.

What you'll build

The trigger opens one to five actions arranged across the upper half of a circle. Every item moves from the trigger to its final coordinate, and opacity and scale derive from the same spring progress. Tapping an action closes the menu and invokes that action's callback. A compact example uses generic note, photo, and saved-item actions with plain text symbols.

The component is a presentation layer. Your callbacks own application behavior. For navigation actions in an Expo Router application, connect the callback in the screen or layout that owns the route rather than embedding route knowledge inside the reusable menu.

Prerequisites

The examples use Expo SDK 57, React Native 0.86.3, Reanimated 4.5.1, and react-native-worklets 0.10.1. Gesture examples use Gesture Handler ~2.32.0. Resolve compatible versions with Expo rather than choosing the latest npm release:

npx expo install react-native-reanimated react-native-worklets

Use bunx expo install if your project uses Bun. The SDK 57 Reanimated documentation explains the automatic Babel setup. Keep gesture screens inside a GestureHandlerRootView as shown in the sample; if your application already provides one, reuse that root. These examples belong in a component file outside the Expo Router route tree. Render the exported example from your screen.

Build the component

Create CircleMenu.tsx. The file includes the reusable component and a small usage example.

import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  type SharedValue,
} from 'react-native-reanimated';

type Action = {
  id: string;
  label: string;
  symbol: string;
  onPress: () => void;
};
type ActionProps = {
  action: Action;
  index: number;
  count: number;
  radius: number;
  open: boolean;
  progress: SharedValue<number>;
  reducedMotion: boolean;
  close: () => void;
};
const SIZE = 48;

function RadialAction({
  action,
  index,
  count,
  radius,
  open,
  progress,
  reducedMotion,
  close,
}: ActionProps) {
  const angle =
    count === 1 ? -Math.PI / 2 : -Math.PI + (index * Math.PI) / (count - 1);
  const style = useAnimatedStyle(() => {
    const phase = progress.get();
    return {
      opacity: Math.min(1, Math.max(0, phase)),
      transform: [
        { translateX: Math.cos(angle) * radius * (reducedMotion ? 1 : phase) },
        { translateY: Math.sin(angle) * radius * (reducedMotion ? 1 : phase) },
        { scale: reducedMotion ? 1 : 0.65 + 0.35 * phase },
      ],
    };
  });
  return (
    <Animated.View
      pointerEvents={open ? 'auto' : 'none'}
      accessibilityElementsHidden={!open}
      importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}
      style={[styles.position, style]}
    >
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={action.label}
        disabled={!open}
        onPress={() => {
          close();
          action.onPress();
        }}
        style={styles.action}
      >
        <Text style={styles.symbol}>{action.symbol}</Text>
      </Pressable>
    </Animated.View>
  );
}

export function CircleMenu({ actions }: { actions: Action[] }) {
  const [open, setOpen] = useState(false);
  const [width, setWidth] = useState(0);
  const progress = useSharedValue(0);
  const reducedMotion = useReducedMotion();
  const radius = Math.min(112, Math.max(0, (width - SIZE) / 2));
  useEffect(() => {
    progress.set(
      reducedMotion
        ? Number(open)
        : withSpring(Number(open), {
            damping: 18,
            stiffness: 220,
            overshootClamping: true,
          }),
    );
  }, [open, progress, reducedMotion]);
  if (actions.length < 1 || actions.length > 5)
    throw new Error('Provide one to five menu actions.');
  const items = actions;
  // Adjacent centers must remain at least one touch-target diameter apart.
  const separation =
    items.length < 2
      ? SIZE
      : 2 * radius * Math.sin(Math.PI / (2 * (items.length - 1)));
  const useList = separation < SIZE + 8;
  return (
    <View
      onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
      style={[styles.root, { height: useList ? items.length * 48 + 96 : 224 }]}
    >
      {useList ? (
        open ? (
          <View style={styles.list}>
            {items.map((action) => (
              <Pressable
                key={action.id}
                accessibilityRole="button"
                onPress={() => {
                  setOpen(false);
                  action.onPress();
                }}
                style={styles.listAction}
              >
                <Text>{action.label}</Text>
              </Pressable>
            ))}
          </View>
        ) : null
      ) : (
        items.map((action, index) => (
          <RadialAction
            key={action.id}
            action={action}
            index={index}
            count={items.length}
            radius={radius}
            open={open}
            progress={progress}
            reducedMotion={reducedMotion}
            close={() => setOpen(false)}
          />
        ))
      )}
      <Pressable
        accessibilityRole="button"
        accessibilityLabel={open ? 'Close actions' : 'Open actions'}
        accessibilityState={{ expanded: open }}
        onPress={() => setOpen((value) => !value)}
        style={[styles.position, styles.trigger]}
      >
        <Text style={styles.symbol}>{open ? '×' : '+'}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  root: { height: 224, width: '100%' },
  position: {
    position: 'absolute',
    left: '50%',
    bottom: 24,
    marginLeft: -SIZE / 2,
    width: SIZE,
    height: SIZE,
  },
  action: {
    flex: 1,
    borderRadius: SIZE / 2,
    backgroundColor: '#EEEAFB',
    alignItems: 'center',
    justifyContent: 'center',
  },
  trigger: {
    backgroundColor: '#DDD3FF',
    borderRadius: SIZE / 2,
    alignItems: 'center',
    justifyContent: 'center',
  },
  symbol: { fontSize: 24, color: '#292138' },
  list: { gap: 4, paddingHorizontal: 16 },
  listAction: {
    minHeight: 44,
    justifyContent: 'center',
    paddingHorizontal: 12,
    backgroundColor: '#EEEAFB',
    borderRadius: 12,
  },
});

export function MenuExample() {
  const [message, setMessage] = useState('Choose an action');
  return (
    <View style={{ padding: 24 }}>
      <Text accessibilityLiveRegion="polite">{message}</Text>
      <CircleMenu
        actions={[
          {
            id: 'note',
            label: 'New note',
            symbol: 'N',
            onPress: () => setMessage('New note selected'),
          },
          {
            id: 'photo',
            label: 'Add photo',
            symbol: 'P',
            onPress: () => setMessage('Add photo selected'),
          },
          {
            id: 'saved',
            label: 'Saved items',
            symbol: 'S',
            onPress: () => setMessage('Saved items selected'),
          },
        ]}
      />
    </View>
  );
}

Calculate the radial fan

For multiple items, the angle starts at negative pi and ends at zero, placing the first item to the left and the last to the right. Intermediate angles are evenly distributed. A single action gets negative pi over two, placing it directly above the trigger. The final translation is cos(angle) * radius horizontally and sin(angle) * radius vertically.

The measured radius is at most 112 points and never larger than half the available width minus the touch-target diameter. Measuring the component with onLayout makes it work inside a padded screen or narrow sheet instead of assuming the device window is its actual available width. The initial width is zero, so the conservative list fallback is used until layout arrives.

Neighboring centers are separated by the chord length 2 * radius * sin(pi / (2 * (count - 1))). The component compares that distance against the forty-eight-point target plus an eight-point gap. If the fan would overlap targets, it switches to a labeled list. Do not shrink hit targets until they technically fit; change the layout instead.

One interruptible opening spring

React stores open, a discrete state needed for hit testing and accessibility. The effect writes zero or one into the shared progress, either directly for reduced motion or through a spring. Each action derives its transform and opacity from that same progress. There are no separate opening timers to get out of sync when the trigger is tapped repeatedly.

Opening and closing replace the animation on the same value. A tap during movement retargets the spring from its current position. overshootClamping prevents the fan from extending beyond its measured resting radius, which keeps targets inside the intended container.

The sample uses ordinary Pressable callbacks on the React Native runtime, so it does not need a worklet-to-React bridge. If you later add drag-to-select, keep drag geometry in shared values and use Worklets 0.10.1's scheduleOnRN only to report a completed action. Do not call React state setters every time the finger moves.

Hide closed actions from interaction

Zero opacity does not remove a button's hit area or its accessibility node. Closed radial actions therefore have pointerEvents="none", disabled Pressables, and both platform accessibility-hiding properties. The list fallback unmounts its actions when closed. The trigger remains a labeled button and reports expanded state.

Closing changes the semantic state immediately, even if the visual spring has a few frames left. That prevents an action from firing from a fading-out target. Each action also closes before invoking its callback. When a callback changes routes, the next screen does not inherit a logically open menu.

Placement, labels, and focus

Keep enough space above and beside the trigger for the entire fan. The sample reserves a local container instead of drawing actions over unrelated controls. A floating bottom action menu must additionally use the screen's safe-area measurements, keyboard position, and navigation-bar clearance. See the Expo safe-area reference.

Text symbols are deliberately generic in the teaching sample. Use your own icon assets and retain full accessibility labels. For unfamiliar actions, render visible text labels too; a compact icon fan should not force users to guess what an action does. Check longer labels and large text in the list layout.

This inline implementation does not trap focus or move focus automatically. Test reading order with the platform screen reader: the action nodes precede the trigger in the tree, and users can navigate back through them once expanded. If your design needs an overlay menu with automatic focus transfer, add that behavior explicitly and restore focus to the trigger on dismissal. Also provide a close control and a backdrop when you cover other content.

Accessibility and reduced motion

Every action is a labeled button with a forty-eight-point target. The trigger says Open actions or Close actions and exposes its expanded state. The example renders feedback as visible text; its polite live region can communicate the chosen action on supported platforms. For native announcements, test and add a single announcement path at the screen level if needed.

Reduced motion places actions at their final coordinates immediately and omits scale travel. Meaning comes from available actions and expanded semantics, not from the fan moving. The fallback list preserves the same callbacks and labels as the radial layout.

Performance and edge cases

The component mounts at most five action views, each with a small animated style. Trigonometry uses a fixed angle per item rather than recalculating a whole layout in React every frame. Only a layout measurement or open-state change updates React state.

Keep IDs stable, callbacks current, and action order deliberate. If the set of actions changes while open, the positions can change without a dedicated transition; close the menu first or extend the geometry model if animated insertion matters. More than five actions is rejected because this design cannot promise usable fan spacing for a larger set. A searchable list or bottom sheet is usually clearer for many actions.

Verify one action, five actions, narrow widths, landscape, rapid open-close taps, selection while opening, large text, and keyboard activation. Make sure the parent does not clip the fan and that invisible actions cannot intercept taps. A pretty centered screenshot will not expose these failures.

Related catalog components

The published Arc Avatar Switcher demonstrates selection along an arc with measured geometry and momentum; it is an artwork picker rather than this action fan. Morphing Action Button offers a different expansion pattern, where one button becomes an editable card. Neither is sold here as a circular menu component.

For continuous selection, see the wheel picker guide. For gesture-driven decisions, see the swipe cards guide. Circular progress belongs in the separate progress bar and ring guide, because it answers a different user task.

FAQ

How do I create a circle menu in React Native?

Measure the available container, assign an angle to each action, and calculate its coordinates with sine and cosine. Animate a shared progress value between the trigger and those final coordinates.

Is a radial menu the same as a progress ring?

No. A radial menu contains interactive choices; a progress ring visualizes a task's completion. Their circular geometry does not make their semantics interchangeable.

How many actions should a circular menu contain?

This implementation accepts one to five. The actual limit comes from available radius, target size, spacing, and label clarity, so use a list when the geometry cannot fit usable targets.

Why can I still tap closed menu items?

Opacity changes appearance only. Disable hidden Pressables, stop pointer events on their containers, and remove their children from the accessibility tree while closed.