Kinetik UI

Lift Menu

.md

Press and hold: the content sinks, then lifts above a dimmed screen while its actions unfold beneath it.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add lift-menu

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/lift-menu.tsx
    View source files

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 children rendered in a modal at the original's position; the original is hidden meanwhile. Keep children free 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

PropTypeDefaultDescription
childrenrequiredReactNode—The content that lifts: a card, a photo, a row.
actionsrequiredreadonly LiftMenuAction[]—
accessibilityLabelrequiredstring—Names the content for screen readers. Its actions are offered as custom actions.
onPress() => void—A plain tap, e.g. to open the item.
delaynumber380How long to hold before it lifts, in ms. Defaults to 380.
menuWidthnumber240Menu width. Defaults to 240.
hapticsbooleantrueA thud as it lifts. Defaults to true.
testIDstring—
styleStyleProp<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' },
});