gestures
Orbit Composer
An adaptive quick-action composer that becomes a full orbit or edge-aware arc.
iOSAndroidExpo Go
hapticsgesture handlerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add expo-haptics react-native-gesture-handler react-native-reanimatedanatomy.tsx<OrbitComposer> <OrbitComposer.Action /> <OrbitComposer.Trigger /></OrbitComposer>Install tailwind variants
pnpm add tailwind-variantsUsage
OrbitComposerExample.tsximport { Feather } from "@expo/vector-icons";import { Text, View } from "react-native";import { OrbitComposer } from "@animations/ui/components/gestures/orbit-composer/uniwind";const actions = [ <OrbitComposer.Action accessibilityLabel="Create photo" id="photo" key="photo" > <View className="items-center gap-0.5"> <Feather name="camera" size={18} /> <Text className="text-[9px] font-extrabold">Photo</Text> </View> </OrbitComposer.Action>, <OrbitComposer.Action accessibilityLabel="Create note" id="note" key="note" > <View className="items-center gap-0.5"> <Feather name="edit-3" size={18} /> <Text className="text-[9px] font-extrabold">Note</Text> </View> </OrbitComposer.Action>,];export function OrbitComposerExample() { return ( <OrbitComposer animation="fan" anchor="bottom" className="h-72 w-full" classNames={{ actionContent: "bg-violet-100", triggerContent: "bg-violet-700", }} onSelect={(id) => console.log(id)} > {actions} <OrbitComposer.Trigger> {({ open }) => ( <Feather color="#ffffff" name={open ? "x" : "plus"} size={24} /> )} </OrbitComposer.Trigger> </OrbitComposer> );}orbit-composer/config.tsexport const ORBIT_COMPOSER_CONFIG = { actionSize: 56, arcSpread: 128, focusDistanceMultiplier: 0.9, maxActions: 6, radius: 104, safePadding: 8, spring: { damping: 17, mass: 0.72, stiffness: 190, }, triggerSize: 64,} as const;orbit-composer/types.tsimport type { ReactNode } from "react";export type OrbitComposerPlacement = | "auto" | "bottom" | "circle" | "left" | "right" | "top";export type OrbitComposerAnimation = | "adaptive" | "bloom" | "cascade" | "fan" | "spin";export type OrbitComposerAnchor = | "bottom" | "bottom-left" | "bottom-right" | "center" | "left" | "right" | "top" | "top-left" | "top-right" | OrbitComposerPosition;export type OrbitComposerAction = { accessibilityLabel: string; children: ReactNode; disabled?: boolean; id: string;};export type OrbitComposerActionProps = OrbitComposerAction;export type OrbitComposerRenderState = { open: boolean;};export type OrbitComposerTriggerProps = { children: | ReactNode | ((state: OrbitComposerRenderState) => ReactNode);};export type OrbitComposerBaseProps = { accessibilityLabel?: string; actionSize?: number; animation?: OrbitComposerAnimation; anchor?: OrbitComposerAnchor; children: ReactNode; defaultOpen?: boolean; disabled?: boolean; haptics?: boolean; onOpenChange?: (open: boolean) => void; onSelect?: (id: string) => void; open?: boolean; placement?: OrbitComposerPlacement; radius?: number; safePadding?: number; triggerSize?: number;};export type OrbitComposerPosition = { x: number; y: number;};orbit-composer/uniwind/index.tsximport { Children, createContext, isValidElement, useContext, type ReactNode,} from "react";import { Pressable } from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { ORBIT_COMPOSER_CONFIG } from "../config";import type { OrbitComposerAction, OrbitComposerActionProps, OrbitComposerBaseProps, OrbitComposerPosition, OrbitComposerRenderState, OrbitComposerTriggerProps,} from "../types";import { useOrbitActionAnimatedStyle, useOrbitComposer,} from "../use-orbit-composer";import { orbitComposerVariants, type OrbitComposerSlot,} from "./variants";type OrbitActionProps = { action: OrbitComposerAction; actionSize: number; animation: ReturnType<typeof useOrbitComposer>["animation"]; anchor: OrbitComposerPosition; classNames?: SlotsToClasses<OrbitComposerSlot>; focusedIndex: ReturnType< typeof useOrbitComposer >["focusedIndex"]; index: number; motion: ReturnType<typeof useOrbitComposer>["motion"]; open: boolean; position: OrbitComposerPosition; progress: ReturnType<typeof useOrbitComposer>["progress"]; select: (index: number) => void;};function OrbitAction({ action, actionSize, animation, anchor, classNames, focusedIndex, index, motion, open, position, progress, select,}: OrbitActionProps) { const { action: actionClass, actionContent } = orbitComposerVariants(); const animatedStyle = useOrbitActionAnimatedStyle({ animation, anchor, focusedIndex, index, motion, position, progress, }); function handlePress() { select(index); } return ( <Animated.View accessibilityElementsHidden={!open} className={actionClass({ className: classNames?.action, })} importantForAccessibility={ open ? "yes" : "no-hide-descendants" } pointerEvents={open ? "auto" : "none"} style={[ { height: actionSize, left: anchor.x - actionSize / 2, top: anchor.y - actionSize / 2, width: actionSize, }, animatedStyle, ]} > <Pressable accessibilityLabel={action.accessibilityLabel} accessibilityRole="button" accessibilityState={{ disabled: action.disabled }} android_ripple={{ borderless: true, color: "rgba(23,23,19,0.12)", }} className={actionContent({ className: classNames?.actionContent, })} disabled={action.disabled} onPress={handlePress} > {action.children} </Pressable> </Animated.View> );}export type OrbitComposerProps = OrbitComposerBaseProps & { className?: string; classNames?: SlotsToClasses<OrbitComposerSlot>;};const OrbitComposerTriggerContext = createContext<OrbitComposerRenderState | null>(null);function OrbitComposerActionSlot({ children,}: OrbitComposerActionProps) { return children;}function OrbitComposerTrigger({ children,}: OrbitComposerTriggerProps) { const state = useContext(OrbitComposerTriggerContext); if (!state) { throw new Error( "OrbitComposer.Trigger must be used inside OrbitComposer.", ); } return typeof children === "function" ? children(state) : children;}function OrbitComposerRoot({ accessibilityLabel = "Open quick actions", actionSize = ORBIT_COMPOSER_CONFIG.actionSize, animation, anchor, children, className, classNames, defaultOpen, disabled = false, haptics, onOpenChange, onSelect, open, placement, radius, safePadding, triggerSize = ORBIT_COMPOSER_CONFIG.triggerSize,}: OrbitComposerProps) { const childNodes = Children.toArray(children); const actions = childNodes.flatMap<OrbitComposerAction>( (child) => isValidElement<OrbitComposerActionProps>(child) && child.type === OrbitComposerActionSlot ? [child.props] : [], ); const triggerNode = childNodes.find( (child) => isValidElement(child) && child.type === OrbitComposerTrigger, ); const { container, trigger, triggerContent } = orbitComposerVariants({ disabled }); const { anchor: resolvedAnchor, animation: resolvedAnimation, focusedIndex, gesture, isOpen, onLayout, motion, positions, progress, select, toggle, } = useOrbitComposer({ actionSize, actions, animation, anchor, defaultOpen, disabled, haptics, onOpenChange, onSelect, open, placement, radius, safePadding, triggerSize, }); return ( <Animated.View className={container({ className: [className, classNames?.container], })} onLayout={onLayout} > {actions.map((action, index) => ( <OrbitAction action={action} actionSize={actionSize} animation={resolvedAnimation} anchor={resolvedAnchor} classNames={classNames} focusedIndex={focusedIndex} index={index} key={action.id} motion={motion} open={isOpen} position={positions[index]} progress={progress} select={select} /> ))} <GestureDetector gesture={gesture}> <Animated.View accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={{ disabled, expanded: isOpen, }} accessible className={trigger({ className: classNames?.trigger, })} onAccessibilityTap={toggle} style={{ height: triggerSize, left: resolvedAnchor.x - triggerSize / 2, top: resolvedAnchor.y - triggerSize / 2, width: triggerSize, }} > <Animated.View className={triggerContent({ className: classNames?.triggerContent, })} > <OrbitComposerTriggerContext.Provider value={{ open: isOpen }} > {triggerNode} </OrbitComposerTriggerContext.Provider> </Animated.View> </Animated.View> </GestureDetector> </Animated.View> );}export const OrbitComposer = Object.assign(OrbitComposerRoot, { Action: OrbitComposerActionSlot, Trigger: OrbitComposerTrigger,});orbit-composer/uniwind/variants.tsimport { tv } from "tailwind-variants";export const orbitComposerSlots = { action: "absolute", actionContent: "flex-1 items-center justify-center rounded-full bg-white shadow-[0_8px_14px_rgba(23,23,19,0.18)] active:scale-[0.96] active:opacity-80", container: "relative", trigger: "absolute z-2", triggerContent: "flex-1 items-center justify-center rounded-full bg-[#171713] shadow-[0_10px_16px_rgba(23,23,19,0.24)]",} as const;export type OrbitComposerSlot = keyof typeof orbitComposerSlots;export const orbitComposerVariants = tv({ slots: orbitComposerSlots, variants: { disabled: { true: { container: "opacity-[0.45]", }, }, },});orbit-composer/use-orbit-composer.tsimport { useCallback, useMemo, useState,} from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import { runOnJS, type DerivedValue, type SharedValue, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withSpring,} from "react-native-reanimated";import { ORBIT_COMPOSER_CONFIG } from "./config";import type { OrbitComposerAction, OrbitComposerAnimation, OrbitComposerAnchor, OrbitComposerBaseProps, OrbitComposerPlacement, OrbitComposerPosition,} from "./types";type OrbitComposerLayout = { height: number; width: number;};type OrbitComposerMotion = "arc" | "orbit";type ResolvedOrbitComposerAnimation = Exclude< OrbitComposerAnimation, "adaptive">;type UseOrbitComposerOptions = Pick< OrbitComposerBaseProps, | "actionSize" | "animation" | "anchor" | "defaultOpen" | "disabled" | "haptics" | "onOpenChange" | "onSelect" | "open" | "placement" | "radius" | "safePadding" | "triggerSize"> & { actions: readonly OrbitComposerAction[];};function clamp(value: number, minimum: number, maximum: number) { "worklet"; return Math.min(maximum, Math.max(minimum, value));}function getDirectionalSpace( anchor: OrbitComposerPosition, layout: OrbitComposerLayout, safePadding: number,) { return { bottom: layout.height - anchor.y - safePadding, left: anchor.x - safePadding, right: layout.width - anchor.x - safePadding, top: anchor.y - safePadding, };}function resolveGeometry( actionSize: number, anchor: OrbitComposerPosition, layout: OrbitComposerLayout, placement: OrbitComposerPlacement, radius: number, safePadding: number,): { centerAngle: number; fullCircle: boolean; spread: number;} { if (placement === "circle") { return { centerAngle: -90, fullCircle: true, spread: 360, }; } if (placement !== "auto") { return { centerAngle: getPlacementCenterAngle(placement), fullCircle: false, spread: ORBIT_COMPOSER_CONFIG.arcSpread, }; } const space = getDirectionalSpace(anchor, layout, safePadding); const requiredSpace = radius + actionSize / 2; if ( Math.min(space.top, space.right, space.bottom, space.left) >= requiredSpace ) { return { centerAngle: -90, fullCircle: true, spread: 360, }; } const nearBottom = space.bottom < requiredSpace; const nearLeft = space.left < requiredSpace; const nearRight = space.right < requiredSpace; const nearTop = space.top < requiredSpace; if (nearBottom && nearRight) { return { centerAngle: -135, fullCircle: false, spread: 90, }; } if (nearBottom && nearLeft) { return { centerAngle: -45, fullCircle: false, spread: 90, }; } if (nearTop && nearRight) { return { centerAngle: 135, fullCircle: false, spread: 90, }; } if (nearTop && nearLeft) { return { centerAngle: 45, fullCircle: false, spread: 90, }; } const directionalSpace = { bottom: space.bottom, left: space.left, right: space.right, top: space.top, } as const; const directions = ["top", "right", "bottom", "left"] as const; const direction = directions.reduce( (bestPlacement, nextPlacement) => directionalSpace[nextPlacement] > directionalSpace[bestPlacement] ? nextPlacement : bestPlacement, "top", ); return { centerAngle: getPlacementCenterAngle(direction), fullCircle: false, spread: ORBIT_COMPOSER_CONFIG.arcSpread, };}function getPlacementCenterAngle( placement: Exclude<OrbitComposerPlacement, "auto" | "circle">,) { const angles = { bottom: 90, left: 180, right: 0, top: -90, } as const; return angles[placement];}function createActionGeometry({ actionCount, actionSize, anchor, layout, placement, radius, safePadding,}: { actionCount: number; actionSize: number; anchor: OrbitComposerPosition; layout: OrbitComposerLayout; placement: OrbitComposerPlacement; radius: number; safePadding: number;}) { const geometry = resolveGeometry( actionSize, anchor, layout, placement, radius, safePadding, ); const angleStep = actionCount <= 1 ? geometry.spread : geometry.spread / (geometry.fullCircle ? actionCount : actionCount - 1); const minimumRadius = actionCount <= 1 ? radius : (actionSize + safePadding) / (2 * Math.sin(((angleStep * Math.PI) / 180) / 2)); const resolvedRadius = Math.max(radius, minimumRadius); const actionRadius = actionSize / 2; const minimumX = safePadding + actionRadius; const maximumX = layout.width - safePadding - actionRadius; const minimumY = safePadding + actionRadius; const maximumY = layout.height - safePadding - actionRadius; const motion: OrbitComposerMotion = geometry.fullCircle ? "orbit" : "arc"; return { motion, positions: Array.from( { length: actionCount }, (_value, index) => { const angle = geometry.fullCircle ? geometry.centerAngle + (index / actionCount) * geometry.spread : geometry.centerAngle - geometry.spread / 2 + (index / Math.max(1, actionCount - 1)) * geometry.spread; const radians = (angle * Math.PI) / 180; return { x: clamp( anchor.x + Math.cos(radians) * resolvedRadius, minimumX, maximumX, ), y: clamp( anchor.y + Math.sin(radians) * resolvedRadius, minimumY, maximumY, ), }; }, ), };}function resolveAnchor( anchor: OrbitComposerAnchor | undefined, layout: OrbitComposerLayout, safePadding: number, triggerSize: number,): OrbitComposerPosition { if (typeof anchor === "object") { return anchor; } const edgeInset = safePadding + triggerSize / 2; const horizontal = { center: layout.width / 2, left: edgeInset, right: layout.width - edgeInset, } as const; const vertical = { bottom: layout.height - edgeInset, center: layout.height / 2, top: edgeInset, } as const; const anchors = { bottom: { x: horizontal.center, y: vertical.bottom, }, "bottom-left": { x: horizontal.left, y: vertical.bottom, }, "bottom-right": { x: horizontal.right, y: vertical.bottom, }, center: { x: horizontal.center, y: vertical.center, }, left: { x: horizontal.left, y: vertical.center, }, right: { x: horizontal.right, y: vertical.center, }, top: { x: horizontal.center, y: vertical.top, }, "top-left": { x: horizontal.left, y: vertical.top, }, "top-right": { x: horizontal.right, y: vertical.top, }, } as const; return anchors[anchor ?? "center"];}export function useOrbitComposer({ actionSize = ORBIT_COMPOSER_CONFIG.actionSize, actions, animation = "adaptive", anchor: providedAnchor, defaultOpen = false, disabled = false, haptics = true, onOpenChange, onSelect, open, placement = "auto", radius = ORBIT_COMPOSER_CONFIG.radius, safePadding = ORBIT_COMPOSER_CONFIG.safePadding, triggerSize = ORBIT_COMPOSER_CONFIG.triggerSize,}: UseOrbitComposerOptions) { if (actions.length > ORBIT_COMPOSER_CONFIG.maxActions) { throw new Error( `OrbitComposer supports up to ${ORBIT_COMPOSER_CONFIG.maxActions} actions.`, ); } if ( actionSize <= 0 || radius <= 0 || triggerSize <= 0 || safePadding < 0 ) { throw new Error( "OrbitComposer requires positive sizes and a non-negative safePadding.", ); } const [layout, setLayout] = useState<OrbitComposerLayout>({ height: 0, width: 0, }); const [internalOpen, setInternalOpen] = useState(defaultOpen); const focusedIndex = useSharedValue(-1); const reducedMotion = useReducedMotion(); const isOpen = open ?? internalOpen; const anchor = resolveAnchor( providedAnchor, layout, safePadding, triggerSize, ); const actionGeometry = useMemo( () => createActionGeometry({ actionCount: actions.length, actionSize, anchor, layout, placement, radius, safePadding, }), [ actionSize, actions.length, anchor.x, anchor.y, layout, placement, radius, safePadding, ], ); const { motion, positions } = actionGeometry; const disabledActions = useMemo( () => actions.map((action) => Boolean(action.disabled)), [actions], ); const progress = useDerivedValue(() => reducedMotion ? Number(isOpen) : withSpring( Number(isOpen), ORBIT_COMPOSER_CONFIG.spring, ), ); const setOpen = useCallback( (nextOpen: boolean) => { if (disabled) { return; } if (open === undefined) { setInternalOpen(nextOpen); } onOpenChange?.(nextOpen); }, [disabled, onOpenChange, open], ); const close = useCallback(() => { setOpen(false); }, [setOpen]); const toggle = useCallback(() => { setOpen(!isOpen); }, [isOpen, setOpen]); const select = useCallback( (index: number) => { const action = actions[index]; if (!action || action.disabled || disabled) { return; } if (haptics) { void Haptics.impactAsync( Haptics.ImpactFeedbackStyle.Light, ); } onSelect?.(action.id); setOpen(false); }, [actions, disabled, haptics, onSelect, setOpen], ); const notifyFocus = useCallback(() => { if (haptics) { void Haptics.selectionAsync(); } }, [haptics]); const onLayout = useCallback((event: LayoutChangeEvent) => { const { height, width } = event.nativeEvent.layout; setLayout((currentLayout) => { if ( currentLayout.height === height && currentLayout.width === width ) { return currentLayout; } return { height, width }; }); }, []); const gesture = useMemo(() => { const pan = Gesture.Pan() .enabled(!disabled && actions.length > 0) .minDistance(8) .onStart(() => { runOnJS(setOpen)(true); }) .onUpdate((event) => { const pointerX = anchor.x + event.translationX; const pointerY = anchor.y + event.translationY; let nextFocusedIndex = -1; let nearestDistance = actionSize * ORBIT_COMPOSER_CONFIG.focusDistanceMultiplier; positions.forEach((position, index) => { const distance = Math.hypot( pointerX - position.x, pointerY - position.y, ); if ( distance < nearestDistance && !disabledActions[index] ) { nearestDistance = distance; nextFocusedIndex = index; } }); if (nextFocusedIndex !== focusedIndex.get()) { focusedIndex.set(nextFocusedIndex); if (nextFocusedIndex >= 0) { runOnJS(notifyFocus)(); } } }) .onEnd(() => { const selectedIndex = focusedIndex.get(); focusedIndex.set(-1); if (selectedIndex >= 0) { runOnJS(select)(selectedIndex); return; } runOnJS(close)(); }) .onFinalize(() => { focusedIndex.set(-1); }); const tap = Gesture.Tap() .enabled(!disabled) .onEnd((_event, success) => { if (success) { runOnJS(toggle)(); } }); return Gesture.Exclusive(pan, tap); }, [ actionSize, anchor.x, anchor.y, close, disabled, disabledActions, focusedIndex, notifyFocus, positions, select, setOpen, toggle, ]); return { anchor, close, focusedIndex, gesture, isOpen, onLayout, positions, progress, select, toggle, motion, animation, };}export function useOrbitActionAnimatedStyle({ animation, anchor, focusedIndex, index, motion, position, progress,}: { animation: OrbitComposerAnimation; anchor: OrbitComposerPosition; focusedIndex: SharedValue<number>; index: number; motion: OrbitComposerMotion; position: OrbitComposerPosition; progress: DerivedValue<number>;}) { let resolvedAnimation: ResolvedOrbitComposerAnimation = animation === "adaptive" ? "bloom" : animation; if (animation === "adaptive" && motion === "arc") { resolvedAnimation = "fan"; } let stagger = 0; if (resolvedAnimation === "fan") { stagger = index * 0.075; } if (resolvedAnimation === "cascade") { stagger = index * 0.06; } if (resolvedAnimation === "spin") { stagger = index * 0.035; } return useAnimatedStyle(() => { const localProgress = clamp( (progress.get() - stagger) / (1 - stagger), 0, 1, ); const focused = focusedIndex.get() === index; const deltaX = position.x - anchor.x; const deltaY = position.y - anchor.y; const distance = Math.max( 1, Math.hypot(deltaX, deltaY), ); const followsCurve = resolvedAnimation === "fan" || resolvedAnimation === "spin"; const bend = (resolvedAnimation === "spin" ? 28 : 18) + index * 2; const controlX = deltaX * 0.45 + (-deltaY / distance) * bend; const controlY = deltaY * 0.45 + (deltaX / distance) * bend; const translateX = followsCurve ? 2 * (1 - localProgress) * localProgress * controlX + localProgress ** 2 * deltaX : deltaX * localProgress; const translateY = followsCurve ? 2 * (1 - localProgress) * localProgress * controlY + localProgress ** 2 * deltaY : deltaY * localProgress; let initialScale = 0.48; if (resolvedAnimation === "bloom") { initialScale = 0.34; } if (resolvedAnimation === "cascade") { initialScale = 0.58; } const restingScale = initialScale + localProgress * (1 - initialScale); let entryRotation = 0; if (resolvedAnimation === "spin") { entryRotation = (index % 2 === 0 ? -32 : 32) * (1 - localProgress); } if (resolvedAnimation === "bloom") { entryRotation = (index % 2 === 0 ? -18 : 18) * (1 - localProgress); } if (resolvedAnimation === "fan") { entryRotation = -12 * (1 - localProgress); } return { opacity: localProgress, transform: [ { translateX }, { translateY }, { rotate: `${entryRotation}deg` }, { scale: restingScale * (focused ? 1.12 : 1), }, ], }; });}