Pop Menu
A menu that unfolds from its trigger's corner, rows sliding in one by one, with a highlight under the finger.
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type PopMenuItem = {
key: string;
label: string;
/** Drawn before the label; receives the colour to use. */
icon?: (color: string) => ReactNode;
/** Red, for actions that remove or leave. */
destructive?: boolean;
disabled?: boolean;
onPress: () => void;
};
export type PopMenuProps = {
items: readonly PopMenuItem[];
/** The trigger's content, e.g. a "more" icon. */
children: ReactNode;
/** Names the trigger, e.g. "More actions". */
accessibilityLabel: string;
/** Which edge of the trigger the menu lines up with. Defaults to `end`. */
align?: 'start' | 'end';
/** Menu width. Defaults to 220. */
width?: number;
/** A tick on open. Defaults to true. */
haptics?: boolean;
testID?: string;
/** Style for the trigger. */
style?: StyleProp<ViewStyle>;
};
type Anchor = { x: number; y: number; width: number; height: number };
const ROW = 46;
const MARGIN = 12;
/**
* A menu that unfolds from its trigger. The panel grows out of the trigger's
* corner, the rows slide in one after another, and a highlight follows the
* finger down the list before the chosen action runs.
*/
export function PopMenu({
items,
children,
accessibilityLabel,
align = 'end',
width = 220,
haptics = true,
testID,
style,
}: PopMenuProps) {
const { colors, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const window = useWindowDimensions();
const trigger = useRef<View>(null);
const [anchor, setAnchor] = useState<Anchor | null>(null);
const [visible, setVisible] = useState(false);
const t = useSharedValue(0);
const hover = useSharedValue(-1);
const height = items.length * ROW + 12;
const below = anchor ? anchor.y + anchor.height + 6 + height < window.height - MARGIN : true;
const left = anchor
? Math.max(
MARGIN,
Math.min(
window.width - width - MARGIN,
align === 'end' ? anchor.x + anchor.width - width : anchor.x,
),
)
: 0;
const top = anchor ? (below ? anchor.y + anchor.height + 6 : anchor.y - height - 6) : 0;
const open = () => {
hover.set(-1);
setAnchor(null);
setVisible(true);
haptic('tick');
// The panel stays invisible until the trigger has been measured, then grows from it.
trigger.current?.measureInWindow((x, y, w, h) => setAnchor({ x, y, width: w, height: h }));
};
useEffect(() => {
if (!visible || !anchor) return;
t.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
}, [visible, anchor, reduced, t]);
const close = (then?: () => void) => {
const done = () => {
setVisible(false);
then?.();
};
if (reduced) {
t.set(0);
done();
return;
}
t.set(
withTiming(0, { duration: durations.quick, easing: easings.exit }, (finished) => {
if (finished) scheduleOnRN(done);
}),
);
};
const panel = useAnimatedStyle(() => ({
opacity: Math.min(1, t.get() * 2),
transform: [{ scale: 0.82 + t.get() * 0.18 }],
}));
const originX = anchor ? Math.max(0, Math.min(width, anchor.x + anchor.width / 2 - left)) : width;
return (
<>
<Pressable
ref={trigger}
testID={testID}
onPress={open}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ expanded: visible }}
style={({ pressed }) => [
styles.trigger,
{
borderRadius: radius.pill,
backgroundColor: pressed ? colors.surfaceSunken : undefined,
},
style,
]}
>
{children}
</Pressable>
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={() => close()}
statusBarTranslucent
navigationBarTranslucent
>
{/* The modal is its own window, so focus stays inside; escape closes it. */}
<View style={StyleSheet.absoluteFill} onAccessibilityEscape={() => close()}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => close()}
accessibilityRole="button"
accessibilityLabel="Close menu"
/>
<Animated.View
style={[
styles.panel,
{
left,
top,
width,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
boxShadow: shadow.lg,
transformOrigin: `${originX}px ${below ? '0%' : '100%'}`,
},
panel,
]}
>
{items.map((item, i) => (
<Row
key={item.key}
item={item}
index={i}
count={items.length}
t={t}
hover={hover}
onSelect={() => close(item.onPress)}
/>
))}
</Animated.View>
</View>
</Modal>
</>
);
}
type RowProps = {
item: PopMenuItem;
index: number;
count: number;
t: SharedValue<number>;
hover: SharedValue<number>;
onSelect: () => void;
};
function Row({ item, index, count, t, hover, onSelect }: RowProps) {
const { colors, font, radius } = useKinetikTheme();
const ink = item.destructive ? colors.danger : colors.text;
const row = useAnimatedStyle(() => {
// Rows arrive one after another as the panel opens.
const start = (index / Math.max(1, count)) * 0.45;
const p = Math.max(0, Math.min(1, (t.get() - start) / 0.55));
return { opacity: p, transform: [{ translateY: (1 - p) * -8 }] };
});
const highlight = useAnimatedStyle(() => ({ opacity: hover.get() === index ? 1 : 0 }));
return (
<Animated.View style={row}>
<Pressable
disabled={item.disabled}
onPressIn={() => hover.set(index)}
onPressOut={() => hover.set(-1)}
onPress={onSelect}
accessibilityRole="menuitem"
accessibilityLabel={item.label}
accessibilityState={{ disabled: item.disabled }}
style={[styles.row, { opacity: item.disabled ? 0.4 : 1 }]}
>
<Animated.View
style={[
styles.highlight,
{ backgroundColor: withAlpha(ink, 0.08), borderRadius: radius.sm },
highlight,
]}
/>
{item.icon ? <View style={styles.icon}>{item.icon(ink)}</View> : null}
<Text style={{ color: ink, fontSize: font.size.md, fontWeight: font.weight.medium }}>
{item.label}
</Text>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
trigger: {
minWidth: minTouchTarget,
minHeight: minTouchTarget,
alignItems: 'center',
justifyContent: 'center',
},
panel: { position: 'absolute', padding: 6, borderWidth: StyleSheet.hairlineWidth },
row: {
height: ROW,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 12,
},
// A faint wash of the row's own ink, so destructive rows highlight in red.
highlight: { ...StyleSheet.absoluteFill },
icon: { width: 20, alignItems: 'center' },
});Installation
npx kinetik-ui add pop-menupnpm dlx kinetik-ui add pop-menubunx kinetik-ui add pop-menuyarn dlx kinetik-ui add pop-menuThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/pop-menu.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/pop-menu.tsx
Usage
import { PopMenu } from '@/components/kinetik/pop-menu';
<PopMenu
accessibilityLabel="More actions"
items={[
{ key: 'share', label: 'Share', onPress: share },
{ key: 'rename', label: 'Rename', onPress: rename },
{ key: 'delete', label: 'Delete', destructive: true, onPress: remove },
]}
>
<MoreIcon />
</PopMenu>;The children are the trigger's content. Each item can take an icon, a function that receives the colour to draw it in, so destructive rows draw red icons.
When to use
- Secondary actions on a card, a row or a header: share, rename, move, delete.
- A short list of actions that would crowd the screen as buttons.
When not to use
- The main action of a screen. Show it as a button.
- More than about seven items, or items with sub-menus. Use a sheet.
Notes
- The menu opens in a transparent modal, so parents with
overflow: 'hidden'cannot clip it. It is measured from the trigger and kept 12 pt inside the window, opening upwards when there is no room below. - The chosen action runs once the menu has closed, so an action that navigates or opens a sheet does not fight the closing animation.
- Android's back button closes the menu.
- With reduced motion the menu appears and closes in place.
Props
PopMenuProps
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | readonly PopMenuItem[] | — | |
childrenrequired | ReactNode | — | The trigger's content, e.g. a "more" icon. |
accessibilityLabelrequired | string | — | Names the trigger, e.g. "More actions". |
align | 'start' | 'end' | 'end' | Which edge of the trigger the menu lines up with. Defaults to `end`. |
width | number | 220 | Menu width. Defaults to 220. |
haptics | boolean | true | A tick on open. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — | Style for the trigger. |
Performance and accessibility
Performance
- Budget
- A transparent modal with plain views, mounted only while open; one shared value opens it and one tracks the pressed row.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.85× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 141 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The menu appears and closes in place, without growing or the rows sliding.
- Screen readers
- The trigger is a button with an expanded state; items are menu items, disabled ones marked as such. The modal keeps focus inside; a Close menu button covers the backdrop and the escape gesture closes it.
- Touch target
- The trigger is at least 44 pt and each row is 46 pt tall.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/pop-menu.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings, spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';
export type PopMenuItem = {
key: string;
label: string;
/** Drawn before the label; receives the colour to use. */
icon?: (color: string) => ReactNode;
/** Red, for actions that remove or leave. */
destructive?: boolean;
disabled?: boolean;
onPress: () => void;
};
export type PopMenuProps = {
items: readonly PopMenuItem[];
/** The trigger's content, e.g. a "more" icon. */
children: ReactNode;
/** Names the trigger, e.g. "More actions". */
accessibilityLabel: string;
/** Which edge of the trigger the menu lines up with. Defaults to `end`. */
align?: 'start' | 'end';
/** Menu width. Defaults to 220. */
width?: number;
/** A tick on open. Defaults to true. */
haptics?: boolean;
testID?: string;
/** Style for the trigger. */
style?: StyleProp<ViewStyle>;
};
type Anchor = { x: number; y: number; width: number; height: number };
const ROW = 46;
const MARGIN = 12;
/**
* A menu that unfolds from its trigger. The panel grows out of the trigger's
* corner, the rows slide in one after another, and a highlight follows the
* finger down the list before the chosen action runs.
*/
export function PopMenu({
items,
children,
accessibilityLabel,
align = 'end',
width = 220,
haptics = true,
testID,
style,
}: PopMenuProps) {
const { colors, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const window = useWindowDimensions();
const trigger = useRef<View>(null);
const [anchor, setAnchor] = useState<Anchor | null>(null);
const [visible, setVisible] = useState(false);
const t = useSharedValue(0);
const hover = useSharedValue(-1);
const height = items.length * ROW + 12;
const below = anchor ? anchor.y + anchor.height + 6 + height < window.height - MARGIN : true;
const left = anchor
? Math.max(
MARGIN,
Math.min(
window.width - width - MARGIN,
align === 'end' ? anchor.x + anchor.width - width : anchor.x,
),
)
: 0;
const top = anchor ? (below ? anchor.y + anchor.height + 6 : anchor.y - height - 6) : 0;
const open = () => {
hover.set(-1);
setAnchor(null);
setVisible(true);
haptic('tick');
// The panel stays invisible until the trigger has been measured, then grows from it.
trigger.current?.measureInWindow((x, y, w, h) => setAnchor({ x, y, width: w, height: h }));
};
useEffect(() => {
if (!visible || !anchor) return;
t.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
}, [visible, anchor, reduced, t]);
const close = (then?: () => void) => {
const done = () => {
setVisible(false);
then?.();
};
if (reduced) {
t.set(0);
done();
return;
}
t.set(
withTiming(0, { duration: durations.quick, easing: easings.exit }, (finished) => {
if (finished) scheduleOnRN(done);
}),
);
};
const panel = useAnimatedStyle(() => ({
opacity: Math.min(1, t.get() * 2),
transform: [{ scale: 0.82 + t.get() * 0.18 }],
}));
const originX = anchor ? Math.max(0, Math.min(width, anchor.x + anchor.width / 2 - left)) : width;
return (
<>
<Pressable
ref={trigger}
testID={testID}
onPress={open}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ expanded: visible }}
style={({ pressed }) => [
styles.trigger,
{
borderRadius: radius.pill,
backgroundColor: pressed ? colors.surfaceSunken : undefined,
},
style,
]}
>
{children}
</Pressable>
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={() => close()}
statusBarTranslucent
navigationBarTranslucent
>
{/* The modal is its own window, so focus stays inside; escape closes it. */}
<View style={StyleSheet.absoluteFill} onAccessibilityEscape={() => close()}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => close()}
accessibilityRole="button"
accessibilityLabel="Close menu"
/>
<Animated.View
style={[
styles.panel,
{
left,
top,
width,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
boxShadow: shadow.lg,
transformOrigin: `${originX}px ${below ? '0%' : '100%'}`,
},
panel,
]}
>
{items.map((item, i) => (
<Row
key={item.key}
item={item}
index={i}
count={items.length}
t={t}
hover={hover}
onSelect={() => close(item.onPress)}
/>
))}
</Animated.View>
</View>
</Modal>
</>
);
}
type RowProps = {
item: PopMenuItem;
index: number;
count: number;
t: SharedValue<number>;
hover: SharedValue<number>;
onSelect: () => void;
};
function Row({ item, index, count, t, hover, onSelect }: RowProps) {
const { colors, font, radius } = useKinetikTheme();
const ink = item.destructive ? colors.danger : colors.text;
const row = useAnimatedStyle(() => {
// Rows arrive one after another as the panel opens.
const start = (index / Math.max(1, count)) * 0.45;
const p = Math.max(0, Math.min(1, (t.get() - start) / 0.55));
return { opacity: p, transform: [{ translateY: (1 - p) * -8 }] };
});
const highlight = useAnimatedStyle(() => ({ opacity: hover.get() === index ? 1 : 0 }));
return (
<Animated.View style={row}>
<Pressable
disabled={item.disabled}
onPressIn={() => hover.set(index)}
onPressOut={() => hover.set(-1)}
onPress={onSelect}
accessibilityRole="menuitem"
accessibilityLabel={item.label}
accessibilityState={{ disabled: item.disabled }}
style={[styles.row, { opacity: item.disabled ? 0.4 : 1 }]}
>
<Animated.View
style={[
styles.highlight,
{ backgroundColor: withAlpha(ink, 0.08), borderRadius: radius.sm },
highlight,
]}
/>
{item.icon ? <View style={styles.icon}>{item.icon(ink)}</View> : null}
<Text style={{ color: ink, fontSize: font.size.md, fontWeight: font.weight.medium }}>
{item.label}
</Text>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
trigger: {
minWidth: minTouchTarget,
minHeight: minTouchTarget,
alignItems: 'center',
justifyContent: 'center',
},
panel: { position: 'absolute', padding: 6, borderWidth: StyleSheet.hairlineWidth },
row: {
height: ROW,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 12,
},
// A faint wash of the row's own ink, so destructive rows highlight in red.
highlight: { ...StyleSheet.absoluteFill },
icon: { width: 20, alignItems: 'center' },
});