Long-press menu anchored to its trigger.
components/ui/context-menu/context-menu.tsx@lumioui/core@latestreact-native-reanimated@*react-native-safe-area-context@*| actions | ContextMenuAction[] | Menu actions shown on long-press. |
| children | React.ReactNode | Wrapped element. |
| staggerMs? | number | Delay between each menu row's cascade entrance, in ms. Default 30. |
| menuClassName? | string | Extra classes for the menu panel. |
| className? | string | Extra classes for the trigger wrapper. |
Long-press menu anchored to its trigger.
import { ContextMenu } from "@/components/ui/context-menu/context-menu";
export function Example() {
return (
<ContextMenu />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Modal, Pressable, View, Text, useWindowDimensions, type PressableProps, type GestureResponderEvent, } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming } from "react-native-reanimated";
import { cn, spring, timing, useHaptic, usePressAnimation, useReducedMotion, StaggeredItem, AnimatedPressable } from "@lumioui/core";
import { Icon, type IconName } from "../icon";
import { Glass } from "../glass";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export interface ContextMenuAction {
label: string;
/** Leading icon (iOS SF Symbol idiom — left of the label). */
icon?: IconName;
destructive?: boolean;
disabled?: boolean;
onPress?: () => void;
}
export interface ContextMenuProps extends Omit<PressableProps, "children"> {
/** Menu actions shown on long-press. */
actions: ContextMenuAction[];
/** Wrapped element. */
children: React.ReactNode;
/** Delay between each menu row's cascade entrance, in ms. Default 30. */
staggerMs?: number;
/** Extra classes for the menu panel. */
menuClassName?: string;
/** Extra classes for the trigger wrapper. */
className?: string;
}
const MENU_WIDTH = 200;
const ESTIMATED_ITEM_H = 48;
/** Single menu row — own component so it can hold a usePressAnimation hook. */
function MenuItem({
action,
bordered,
onSelect,
}: {
action: ContextMenuAction;
bordered: boolean;
onSelect: (action: ContextMenuAction) => void;
}) {
// Whole-row press feedback: the row dips shallowly and an ink wash covers
// the full width (same idiom as DropdownMenuItem) — not just the text.
const press = usePressAnimation({ pressedScale: 0.98 });
const ink = useSharedValue(0);
const inkStyle = useAnimatedStyle(() => ({ opacity: ink.value * 0.1 }));
return (
<AnimatedPressable
disabled={action.disabled}
onPress={() => onSelect(action)}
onPressIn={() => {
press.onPressIn();
ink.value = withTiming(1, timing.instant);
}}
onPressOut={() => {
press.onPressOut();
ink.value = withTiming(0, timing.normal);
}}
hitSlop={6}
style={press.animatedStyle}
className={cn(
"min-h-[44px] justify-center px-4",
bordered && "border-t border-border",
action.disabled && "opacity-40"
)}
accessibilityRole="menuitem"
accessibilityLabel={action.label}
accessibilityState={{ disabled: action.disabled }}
// accessibilityState isn't forwarded to the DOM on web — mirror it.
aria-disabled={action.disabled ?? false}
>
{/* Ink overlay — held state, clipped by the menu's overflow-hidden. */}
<Animated.View
pointerEvents="none"
className="bg-foreground"
style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }, inkStyle]}
/>
<View className="flex-row items-center gap-2.5">
{action.icon ? (
<Icon
name={action.icon}
size="sm"
color={action.destructive ? "destructive" : "foreground"}
/>
) : null}
<Text
className={cn(
"text-base",
action.destructive ? "text-destructive" : "text-card-foreground"
)}
>
{action.label}
</Text>
</View>
</AnimatedPressable>
);
}
/**
* ContextMenu — long-press the wrapped element to show a menu
* anchored near the press point.
*/
export const ContextMenu = forwardRef<React.ElementRef<typeof Pressable>, ContextMenuProps>(
function ContextMenu({ actions, children, staggerMs = 30, menuClassName, className, onLongPress, onPressIn, onPressOut, ...props }, ref) {
const haptics = useHaptic();
const reduce = useReducedMotion();
const { width: winW, height: winH } = useWindowDimensions();
const insets = useSafeAreaInsets();
// The wrapped element dips slightly while held — reads as the OS
// "picking up" the element before the menu unfolds (iOS idiom).
const press = usePressAnimation({ pressedScale: 0.97 });
const [visible, setVisible] = React.useState(false);
// Kept mounted through the recede animation so the menu shrinks out
// instead of blinking away (deferred-unmount pattern from Select).
const [rendered, setRendered] = React.useState(false);
const [point, setPoint] = React.useState({ x: 0, y: 0 });
const scale = useSharedValue(0.9);
const opacity = useSharedValue(0);
// Deferred-unmount timer — tracked so a re-open during the recede window
// (or an unmount) can cancel it instead of unmounting the live menu.
const unmountTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
if (unmountTimer.current) clearTimeout(unmountTimer.current);
},
[]
);
const open = (e: GestureResponderEvent) => {
haptics.medium();
if (unmountTimer.current) {
clearTimeout(unmountTimer.current);
unmountTimer.current = null;
}
setPoint({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setRendered(true);
setVisible(true);
// Reduced motion: skip the scale pop, keep the fade.
scale.value = reduce ? 1 : withSpring(1, spring.surface);
opacity.value = withTiming(1, timing.fast);
};
// Exit recede: ~150ms scale+fade (timing.fast), then unmount. Exits run
// faster than the spring entrance so dismissal feels decisive.
const dismiss = () => {
setVisible(false);
if (reduce) {
scale.value = 0.9;
opacity.value = 0;
} else {
scale.value = withTiming(0.9, timing.fast);
opacity.value = withTiming(0, timing.fast);
}
if (unmountTimer.current) clearTimeout(unmountTimer.current);
unmountTimer.current = setTimeout(() => {
setRendered(false);
unmountTimer.current = null;
}, timing.fast.duration);
};
// Close haptic lives on the backdrop path only — item presses
// already fire their own selection haptic.
const close = () => {
haptics.selection();
dismiss();
};
const closeSilently = () => dismiss();
const menuH = actions.length * ESTIMATED_ITEM_H;
const left = Math.max(8 + insets.left, Math.min(point.x - MENU_WIDTH / 2, winW - MENU_WIDTH - 8 - insets.right));
const top = Math.max(insets.top + 8, Math.min(point.y + 8, winH - menuH - 16 - insets.bottom));
// Unfold from the finger, not the center: the transform origin is the
// press point expressed in menu-relative percentages, clamped so a
// clamped menu still grows from its nearest corner.
const originX = Math.max(0, Math.min(100, ((point.x - left) / MENU_WIDTH) * 100));
const originY = Math.max(0, Math.min(100, ((point.y - top) / menuH) * 100));
const menuStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
// Grow out of the long-press point (computed at render).
transformOrigin: `${originX}% ${originY}%`,
transform: [{ scale: scale.value }],
}));
return (
<>
<AnimatedPressable
ref={ref}
onLongPress={(e) => {
open(e);
onLongPress?.(e);
}}
onPressIn={(e) => {
press.onPressIn();
onPressIn?.(e);
}}
onPressOut={(e) => {
press.onPressOut();
onPressOut?.(e);
}}
style={press.animatedStyle}
delayLongPress={350}
accessibilityRole="button"
accessibilityHint="Long press for more options"
accessibilityState={{ expanded: visible }}
// accessibilityState isn't forwarded to the DOM on web — mirror it.
aria-expanded={visible}
aria-haspopup="menu"
className={cn(className)}
{...props}
>
{children}
</AnimatedPressable>
<Modal
visible={rendered}
transparent
animationType="none"
onRequestClose={close}
statusBarTranslucent
>
<View className="flex-1" style={{ width: "100%", height: "100%" }}>
<Pressable className="flex-1 bg-overlay" style={{ width: "100%", height: "100%" }} onPress={close} accessibilityLabel="Close menu" accessibilityRole="button" />
<Animated.View
style={[
{ position: "absolute", top, left, width: MENU_WIDTH },
menuStyle,
]}
className={menuClassName}
accessibilityViewIsModal
accessibilityRole="menu"
>
{/* Liquid-glass menu surface — iOS 26 context menus are glass. */}
<Glass
effect="regular"
className="overflow-hidden rounded-2xl border border-white/20 py-1 shadow-lg"
>
{actions.map((action, i) => (
// Rows cascade in behind the unfolding menu surface.
<StaggeredItem key={`${action.label}-${i}`} index={i} active={visible} staggerMs={staggerMs} slideDistance={6}>
<MenuItem
action={action}
bordered={i > 0}
onSelect={(a) => {
if (a.destructive) haptics.warning();
else haptics.selection();
closeSilently();
a.onPress?.();
}}
/>
</StaggeredItem>
))}
</Glass>
</Animated.View>
</View>
</Modal>
</>
);
}
);
ContextMenu.displayName = "ContextMenu";