Lift Menu
Press and hold: the content sinks, then lifts above a dimmed screen while its actions unfold beneath it.
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} 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 { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type LiftMenuAction = {
key: string;
label: string;
/** Drawn after the label; receives the colour to use. */
icon?: (color: string) => ReactNode;
destructive?: boolean;
onPress: () => void;
};
export type LiftMenuProps = {
/** The content that lifts: a card, a photo, a row. */
children: ReactNode;
actions: readonly LiftMenuAction[];
/** Names the content for screen readers. Its actions are offered as custom actions. */
accessibilityLabel: string;
/** A plain tap, e.g. to open the item. */
onPress?: () => void;
/** How long to hold before it lifts, in ms. Defaults to 380. */
delay?: number;
/** Menu width. Defaults to 240. */
menuWidth?: number;
/** A thud as it lifts. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
type Rect = { x: number; y: number; width: number; height: number };
const ROW = 46;
const MARGIN = 16;
const GAP = 10;
const LIFT = 1.03;
/**
* A context menu that lifts its content. Press and hold: the content sinks a
* little, then rises out of the page above a dimmed screen, moving up if it
* must to make room, and its actions unfold beneath it.
*/
export function LiftMenu({
children,
actions,
accessibilityLabel,
onPress,
delay = 380,
menuWidth = 240,
haptics = true,
testID,
style,
}: LiftMenuProps) {
const { colors, font, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const window = useWindowDimensions();
const ref = useRef<View>(null);
const [rect, setRect] = useState<Rect | null>(null);
const [visible, setVisible] = useState(false);
const press = useSharedValue(0);
const t = useSharedValue(0);
const menuH = actions.length * ROW + 12;
// How far the content moves up so the menu fits below it.
const shift = rect
? Math.max(
Math.min(0, window.height - MARGIN - (rect.y + rect.height + GAP + menuH)),
MARGIN - rect.y,
)
: 0;
const menuLeft = rect
? rect.x + rect.width / 2 > window.width / 2
? Math.max(MARGIN, rect.x + rect.width - menuWidth)
: Math.min(window.width - menuWidth - MARGIN, rect.x)
: 0;
const fromRight = rect ? rect.x + rect.width / 2 > window.width / 2 : false;
const lift = () => {
setRect(null);
setVisible(true);
haptic('thud');
ref.current?.measureInWindow((x, y, width, height) => setRect({ x, y, width, height }));
};
useEffect(() => {
if (!visible || !rect) return;
t.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
}, [visible, rect, reduced, t]);
const close = (then?: () => void) => {
const done = () => {
setVisible(false);
then?.();
};
if (reduced) {
t.set(0);
done();
return;
}
t.set(
withTiming(0, { duration: durations.base, easing: easings.exit }, (finished) => {
if (finished) scheduleOnRN(done);
}),
);
};
const source = useAnimatedStyle(() => ({
transform: [{ scale: 1 - press.get() * 0.03 }],
}));
const scrim = useAnimatedStyle(() => ({ opacity: Math.min(1, t.get()) }));
const preview = useAnimatedStyle(() => ({
transform: [{ translateY: shift * t.get() }, { scale: 0.97 + (LIFT - 0.97) * t.get() }],
}));
const menu = useAnimatedStyle(() => {
const p = Math.max(0, Math.min(1, (t.get() - 0.2) / 0.8));
return {
opacity: p,
transform: [{ translateY: shift * t.get() + (1 - p) * -10 }, { scale: 0.85 + p * 0.15 }],
};
});
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const name = e.nativeEvent.actionName;
if (name === 'activate') return onPress?.();
if (name === 'longpress') return lift();
actions.find((a) => a.key === name)?.onPress();
};
return (
<>
<Pressable
ref={ref}
testID={testID}
onPress={onPress}
onLongPress={lift}
delayLongPress={delay}
// The content sinks while held, so the lift reads as a release.
onPressIn={() => {
if (!reduced) press.set(withTiming(1, { duration: delay, easing: easings.standard }));
}}
onPressOut={() => press.set(withSpring(0, spring('smooth', reduced)))}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Hold for actions"
accessibilityActions={[
...(onPress ? [{ name: 'activate' }] : []),
{ name: 'longpress', label: 'Show actions' },
...actions.map((a) => ({ name: a.key, label: a.label })),
]}
onAccessibilityAction={onAccessibilityAction}
style={style}
>
<Animated.View style={[source, { opacity: visible ? 0 : 1 }]}>{children}</Animated.View>
</Pressable>
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={() => close()}
statusBarTranslucent
navigationBarTranslucent
>
<View style={StyleSheet.absoluteFill} onAccessibilityEscape={() => close()}>
<Animated.View
style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => close()}
accessibilityRole="button"
accessibilityLabel="Close actions"
/>
</Animated.View>
{rect ? (
<Animated.View
pointerEvents="none"
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
style={[
styles.preview,
{ left: rect.x, top: rect.y, width: rect.width, height: rect.height },
preview,
]}
>
{children}
</Animated.View>
) : null}
<Animated.View
style={[
styles.menu,
{
left: menuLeft,
top: rect ? rect.y + rect.height + GAP : 0,
width: menuWidth,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
boxShadow: shadow.lg,
opacity: rect ? undefined : 0,
transformOrigin: fromRight ? 'right top' : 'left top',
},
menu,
]}
>
{actions.map((action, i) => {
const ink = action.destructive ? colors.danger : colors.text;
return (
<Pressable
key={action.key}
onPress={() => close(action.onPress)}
accessibilityRole="menuitem"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.row,
{
borderRadius: radius.sm,
backgroundColor: pressed ? withAlpha(ink, 0.08) : undefined,
},
i > 0 && {
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
},
]}
>
<Text
style={{ color: ink, fontSize: font.size.md, fontWeight: font.weight.medium }}
>
{action.label}
</Text>
{action.icon ? <View style={styles.icon}>{action.icon(ink)}</View> : null}
</Pressable>
);
})}
</Animated.View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
preview: { position: 'absolute' },
menu: { position: 'absolute', padding: 6, borderWidth: StyleSheet.hairlineWidth },
row: {
height: ROW,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
},
icon: { width: 20, alignItems: 'center' },
});Installation
npx kinetik-ui add lift-menupnpm dlx kinetik-ui add lift-menubunx kinetik-ui add lift-menuyarn dlx kinetik-ui add lift-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/lift-menu.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/lift-menu.tsx
Usage
import { LiftMenu } from '@/components/kinetik/lift-menu';
<LiftMenu
accessibilityLabel={photo.title}
onPress={() => openPhoto(photo)}
actions={[
{ key: 'share', label: 'Share', onPress: share },
{ key: 'delete', label: 'Delete', destructive: true, onPress: remove },
]}
>
<PhotoTile photo={photo} />
</LiftMenu>;Wrap any card, tile or row. A tap runs onPress; holding for delay ms lifts the content and shows its actions.
When to use
- Shortcuts on items people already open with a tap: photos, messages, files, places.
- Grids and lists where per-item buttons would crowd the layout.
When not to use
- The only way to reach an action. Long presses are hard to discover, so offer the same actions elsewhere too, for example in a detail screen.
- Content that changes while it is lifted, such as video. The lifted copy is a second render of
children.
Notes
- The lifted content is a copy of
childrenrendered in a modal at the original's position; the original is hidden meanwhile. Keepchildrenfree of side effects on mount. - When there is no room for the menu below, the content moves up first. The menu opens towards the middle of the screen.
- Every action is a custom accessibility action on the content, so screen reader users pick actions without holding.
- With reduced motion nothing sinks or lifts; the menu appears at once.
Props
LiftMenuProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The content that lifts: a card, a photo, a row. |
actionsrequired | readonly LiftMenuAction[] | — | |
accessibilityLabelrequired | string | — | Names the content for screen readers. Its actions are offered as custom actions. |
onPress | () => void | — | A plain tap, e.g. to open the item. |
delay | number | 380 | How long to hold before it lifts, in ms. Defaults to 380. |
menuWidth | number | 240 | Menu width. Defaults to 240. |
haptics | boolean | true | A thud as it lifts. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- A transparent modal mounted only while lifted, holding a second copy of the content. One shared value runs the lift; nothing runs at rest.
- 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 · 168 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The menu and the dimmed screen appear at once; the content does not sink or lift.
- Screen readers
- The content is a button with the hint "Hold for actions", and every action is offered as a custom action, so no long press is needed.
- Touch target
- The content itself is the target; menu rows are 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/lift-menu.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} 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 { withAlpha } from '../../lib/kinetik/tokens/tokens';
export type LiftMenuAction = {
key: string;
label: string;
/** Drawn after the label; receives the colour to use. */
icon?: (color: string) => ReactNode;
destructive?: boolean;
onPress: () => void;
};
export type LiftMenuProps = {
/** The content that lifts: a card, a photo, a row. */
children: ReactNode;
actions: readonly LiftMenuAction[];
/** Names the content for screen readers. Its actions are offered as custom actions. */
accessibilityLabel: string;
/** A plain tap, e.g. to open the item. */
onPress?: () => void;
/** How long to hold before it lifts, in ms. Defaults to 380. */
delay?: number;
/** Menu width. Defaults to 240. */
menuWidth?: number;
/** A thud as it lifts. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
type Rect = { x: number; y: number; width: number; height: number };
const ROW = 46;
const MARGIN = 16;
const GAP = 10;
const LIFT = 1.03;
/**
* A context menu that lifts its content. Press and hold: the content sinks a
* little, then rises out of the page above a dimmed screen, moving up if it
* must to make room, and its actions unfold beneath it.
*/
export function LiftMenu({
children,
actions,
accessibilityLabel,
onPress,
delay = 380,
menuWidth = 240,
haptics = true,
testID,
style,
}: LiftMenuProps) {
const { colors, font, radius, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const window = useWindowDimensions();
const ref = useRef<View>(null);
const [rect, setRect] = useState<Rect | null>(null);
const [visible, setVisible] = useState(false);
const press = useSharedValue(0);
const t = useSharedValue(0);
const menuH = actions.length * ROW + 12;
// How far the content moves up so the menu fits below it.
const shift = rect
? Math.max(
Math.min(0, window.height - MARGIN - (rect.y + rect.height + GAP + menuH)),
MARGIN - rect.y,
)
: 0;
const menuLeft = rect
? rect.x + rect.width / 2 > window.width / 2
? Math.max(MARGIN, rect.x + rect.width - menuWidth)
: Math.min(window.width - menuWidth - MARGIN, rect.x)
: 0;
const fromRight = rect ? rect.x + rect.width / 2 > window.width / 2 : false;
const lift = () => {
setRect(null);
setVisible(true);
haptic('thud');
ref.current?.measureInWindow((x, y, width, height) => setRect({ x, y, width, height }));
};
useEffect(() => {
if (!visible || !rect) return;
t.set(0);
t.set(reduced ? 1 : withSpring(1, spring('smooth')));
}, [visible, rect, reduced, t]);
const close = (then?: () => void) => {
const done = () => {
setVisible(false);
then?.();
};
if (reduced) {
t.set(0);
done();
return;
}
t.set(
withTiming(0, { duration: durations.base, easing: easings.exit }, (finished) => {
if (finished) scheduleOnRN(done);
}),
);
};
const source = useAnimatedStyle(() => ({
transform: [{ scale: 1 - press.get() * 0.03 }],
}));
const scrim = useAnimatedStyle(() => ({ opacity: Math.min(1, t.get()) }));
const preview = useAnimatedStyle(() => ({
transform: [{ translateY: shift * t.get() }, { scale: 0.97 + (LIFT - 0.97) * t.get() }],
}));
const menu = useAnimatedStyle(() => {
const p = Math.max(0, Math.min(1, (t.get() - 0.2) / 0.8));
return {
opacity: p,
transform: [{ translateY: shift * t.get() + (1 - p) * -10 }, { scale: 0.85 + p * 0.15 }],
};
});
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
const name = e.nativeEvent.actionName;
if (name === 'activate') return onPress?.();
if (name === 'longpress') return lift();
actions.find((a) => a.key === name)?.onPress();
};
return (
<>
<Pressable
ref={ref}
testID={testID}
onPress={onPress}
onLongPress={lift}
delayLongPress={delay}
// The content sinks while held, so the lift reads as a release.
onPressIn={() => {
if (!reduced) press.set(withTiming(1, { duration: delay, easing: easings.standard }));
}}
onPressOut={() => press.set(withSpring(0, spring('smooth', reduced)))}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint="Hold for actions"
accessibilityActions={[
...(onPress ? [{ name: 'activate' }] : []),
{ name: 'longpress', label: 'Show actions' },
...actions.map((a) => ({ name: a.key, label: a.label })),
]}
onAccessibilityAction={onAccessibilityAction}
style={style}
>
<Animated.View style={[source, { opacity: visible ? 0 : 1 }]}>{children}</Animated.View>
</Pressable>
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={() => close()}
statusBarTranslucent
navigationBarTranslucent
>
<View style={StyleSheet.absoluteFill} onAccessibilityEscape={() => close()}>
<Animated.View
style={[StyleSheet.absoluteFill, { backgroundColor: colors.scrim }, scrim]}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => close()}
accessibilityRole="button"
accessibilityLabel="Close actions"
/>
</Animated.View>
{rect ? (
<Animated.View
pointerEvents="none"
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
style={[
styles.preview,
{ left: rect.x, top: rect.y, width: rect.width, height: rect.height },
preview,
]}
>
{children}
</Animated.View>
) : null}
<Animated.View
style={[
styles.menu,
{
left: menuLeft,
top: rect ? rect.y + rect.height + GAP : 0,
width: menuWidth,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
boxShadow: shadow.lg,
opacity: rect ? undefined : 0,
transformOrigin: fromRight ? 'right top' : 'left top',
},
menu,
]}
>
{actions.map((action, i) => {
const ink = action.destructive ? colors.danger : colors.text;
return (
<Pressable
key={action.key}
onPress={() => close(action.onPress)}
accessibilityRole="menuitem"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.row,
{
borderRadius: radius.sm,
backgroundColor: pressed ? withAlpha(ink, 0.08) : undefined,
},
i > 0 && {
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
},
]}
>
<Text
style={{ color: ink, fontSize: font.size.md, fontWeight: font.weight.medium }}
>
{action.label}
</Text>
{action.icon ? <View style={styles.icon}>{action.icon(ink)}</View> : null}
</Pressable>
);
})}
</Animated.View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
preview: { position: 'absolute' },
menu: { position: 'absolute', padding: 6, borderWidth: StyleSheet.hairlineWidth },
row: {
height: ROW,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
},
icon: { width: 20, alignItems: 'center' },
});