Kinetik UI

Pop Menu

.md

A menu that unfolds from its trigger's corner, rows sliding in one by one, with a highlight under the finger.

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

Installation

Kinetik CLI
npx kinetik-ui add pop-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/pop-menu.json

Install manually

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

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

PropTypeDefaultDescription
itemsrequiredreadonly PopMenuItem[]—
childrenrequiredReactNode—The trigger's content, e.g. a "more" icon.
accessibilityLabelrequiredstring—Names the trigger, e.g. "More actions".
align'start' | 'end''end'Which edge of the trigger the menu lines up with. Defaults to `end`.
widthnumber220Menu width. Defaults to 220.
hapticsbooleantrueA tick on open. Defaults to true.
testIDstring—
styleStyleProp<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' },
});