Description:
expo-composer-hold-menu is a React Native and Expo chat composer with a press-and-hold recent-photo picker.
Holding the attachment button opens the photo menu, and selected images appear as animated cards in a shelf above the text input.
The component has controlled text, callbacks for attachment and voice actions, and theme-aware glass styling.
Preview

Run the Example
Use a native Expo build for the included modules.
git clone https://github.com/rit3zh/expo-composer-hold-menu
cd expo-composer-hold-menu
bun install
bunx expo prebuild
bun iosFor Android, use the project’s Android script:
bun androidIntegrate the Composer
Copy src/components/composer.tsx with its imported components, hooks, icons, constants, types, and utilities. An existing Expo app needs the matching packages:
bunx expo install react-native-reanimated react-native-worklets react-native-gesture-handler react-native-keyboard-controller react-native-safe-area-context react-native-svg expo-media-library expo-image expo-glass-effect expo-linear-gradient expo-backdropWrap the app once in Gesture Handler and Keyboard Controller. In an Expo Router app, use src/app/_layout.tsx:
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider enabled>
<Stack screenOptions={{ headerShown: false }} />
</KeyboardProvider>
</GestureHandlerRootView>
);
}Basic Usage
The composer calls its recent-photo hook on mount and requests photo permission. Passing recentPhotos overrides the displayed array but does not disable that hook. Modify the hook or split the library-photo path out of the component if a custom-data screen must avoid that permission request.
import { useState } from "react";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { Composer } from "@/components/composer";
export function ChatInput() {
const [message, setMessage] = useState("");
return (
<KeyboardAvoidingView
behavior="padding"
style={{ flex: 1, justifyContent: "flex-end" }}
>
<Composer
value={message}
onChangeText={setMessage}
placeholder="Write a message"
onPhotoSelect={(photo) => console.log("Selected", photo.uri)}
onAttachPress={() => console.log("Open attachment options")}
onDictatePress={() => console.log("Start dictation")}
onVoicePress={() => console.log("Start voice mode")}
/>
</KeyboardAvoidingView>
);
}Composer Props
| Prop | Type or behavior |
|---|---|
value, onChangeText | Controlled text and text-change callback |
placeholder | Input placeholder; default Ask ChatGPT |
recentPhotos | Readonly array of { id: string, uri: string } photo objects |
onAttachPress | Callback for a normal attachment-button tap |
onPhotoSelect | Receives the selected photo object from the hold menu |
onDictatePress | Callback for the microphone button |
onVoicePress | Callback for the voice-mode button |
style | React Native style for the outer container |





