├── assets └── images │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ ├── react-logo.png │ ├── adaptive-icon.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── partial-react-logo.png ├── babel.config.js ├── tsconfig.json ├── .gitignore ├── types └── index.ts ├── app.json ├── components ├── RenderImage.tsx ├── Button.tsx ├── EmojiBar.tsx ├── ReactionMenu.tsx ├── ChatMenu.tsx ├── Bubble.tsx └── ReactionMenuWrapper.tsx ├── app ├── _layout.tsx └── index.tsx ├── package.json ├── README.md └── mock └── chatData.ts /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/splash.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arunabhverma/expo-reaction-menu/HEAD/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 17 | # The following patterns were generated by expo-cli 18 | 19 | expo-env.d.ts 20 | # @end expo-cli -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type CHAT_ITEM = { 2 | id: string; 3 | userId: string; 4 | name: string; 5 | message: string; 6 | image?: string; 7 | reaction: string[]; 8 | }; 9 | 10 | export interface ReactionMenuType { 11 | isMenuOpen: boolean; 12 | setIsMenuOpen: (val: boolean) => void; 13 | isEven: boolean; 14 | children: React.ReactNode; 15 | } 16 | 17 | export interface ModalWrapperProps { 18 | isVisible: boolean; 19 | onClose: () => void; 20 | children: React.ReactNode; 21 | emojiBarHeight: number; 22 | reactionMenuHeight: number; 23 | } 24 | 25 | export interface BubbleType { 26 | setIsMenuOpen: (val: boolean) => void; 27 | isEven: boolean; 28 | item: CHAT_ITEM; 29 | } 30 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-reaction-menu", 4 | "slug": "expo-reaction-menu", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/images/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | } 23 | }, 24 | "web": { 25 | "bundler": "metro", 26 | "output": "static", 27 | "favicon": "./assets/images/favicon.png" 28 | }, 29 | "plugins": [ 30 | "expo-router" 31 | ], 32 | "experiments": { 33 | "typedRoutes": true 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /components/RenderImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | View, 4 | StyleSheet, 5 | ImageProps, 6 | StyleProp, 7 | ImageStyle, 8 | } from "react-native"; 9 | import { Image } from "expo-image"; 10 | 11 | interface RenderImageProps extends Omit { 12 | image: string; 13 | style?: StyleProp; 14 | } 15 | 16 | const RenderImage: React.FC = ({ image, style }) => { 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | const styles = StyleSheet.create({ 29 | container: { 30 | width: "100%", 31 | alignItems: "center", 32 | justifyContent: "center", 33 | }, 34 | image: { 35 | width: "100%", 36 | height: undefined, 37 | aspectRatio: 1.77, 38 | }, 39 | }); 40 | 41 | export default RenderImage; 42 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Pressable, PressableProps } from "react-native"; 2 | import React, { useState } from "react"; 3 | import Animated, { 4 | useAnimatedStyle, 5 | withTiming, 6 | } from "react-native-reanimated"; 7 | 8 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 9 | 10 | const Button = (props: PressableProps) => { 11 | const [isPressed, setIsPressed] = useState(false); 12 | 13 | const animatedStyle = useAnimatedStyle(() => { 14 | return { 15 | transform: [ 16 | { 17 | scale: isPressed 18 | ? withTiming(0.9, { duration: 150 }) 19 | : withTiming(1, { duration: 150 }), 20 | }, 21 | ], 22 | }; 23 | }); 24 | 25 | return ( 26 | setIsPressed(true)} 29 | onTouchEnd={() => setIsPressed(false)} 30 | style={[animatedStyle, props.style]} 31 | > 32 | {props.children} 33 | 34 | ); 35 | }; 36 | 37 | export default Button; 38 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DarkTheme, 3 | DefaultTheme, 4 | ThemeProvider, 5 | } from "@react-navigation/native"; 6 | import { Stack } from "expo-router"; 7 | import { useColorScheme } from "react-native"; 8 | import "react-native-reanimated"; 9 | 10 | declare module "@react-navigation/native" { 11 | export type ExtendedTheme = { 12 | dark: boolean; 13 | colors: { 14 | primary: string; 15 | background: string; 16 | card: string; 17 | text: string; 18 | border: string; 19 | notification: string; 20 | secondary: string; 21 | menu: string; 22 | }; 23 | }; 24 | export function useTheme(): ExtendedTheme; 25 | } 26 | 27 | export default function RootLayout() { 28 | const colorScheme = useColorScheme(); 29 | 30 | let dark = { 31 | ...DarkTheme, 32 | colors: { 33 | ...DarkTheme.colors, 34 | background: "rgb(28,28,28)", 35 | card: "rgb(42, 42, 44)", 36 | primary: "rgb(43,140,246)", 37 | secondary: "rgb(52,52,54)", 38 | menu: "rgb(42,42,44)", 39 | }, 40 | }; 41 | let light = { 42 | ...DefaultTheme, 43 | colors: { 44 | ...DefaultTheme.colors, 45 | background: "white", 46 | card: "rgb(244, 243, 242)", 47 | primary: "rgb(32,126,248)", 48 | secondary: "rgb(230,230,232)", 49 | menu: "white", 50 | }, 51 | }; 52 | const theme = colorScheme === "dark" ? dark : light; 53 | 54 | return ( 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-reaction-menu", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint" 13 | }, 14 | "jest": { 15 | "preset": "jest-expo" 16 | }, 17 | "dependencies": { 18 | "@expo/vector-icons": "^14.0.2", 19 | "@react-navigation/elements": "^1.3.31", 20 | "@react-navigation/native": "^6.0.2", 21 | "expo": "~51.0.28", 22 | "expo-blur": "~13.0.2", 23 | "expo-constants": "~16.0.2", 24 | "expo-font": "~12.0.9", 25 | "expo-image": "~1.12.15", 26 | "expo-linking": "~6.3.1", 27 | "expo-router": "~3.5.23", 28 | "expo-splash-screen": "~0.27.5", 29 | "expo-status-bar": "~1.12.1", 30 | "expo-system-ui": "~3.0.7", 31 | "expo-web-browser": "~13.0.3", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "react-native": "0.74.5", 35 | "react-native-gesture-handler": "~2.16.1", 36 | "react-native-reanimated": "~3.10.1", 37 | "react-native-safe-area-context": "4.10.5", 38 | "react-native-screens": "3.31.1", 39 | "react-native-web": "~0.19.10" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.20.0", 43 | "@types/jest": "^29.5.12", 44 | "@types/react": "~18.2.45", 45 | "@types/react-test-renderer": "^18.0.7", 46 | "jest": "^29.2.1", 47 | "jest-expo": "~51.0.3", 48 | "react-test-renderer": "18.2.0", 49 | "typescript": "~5.3.3" 50 | }, 51 | "private": true 52 | } 53 | -------------------------------------------------------------------------------- /components/EmojiBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, Text } from "react-native"; 3 | import Animated, { 4 | FadeInDown, 5 | LinearTransition, 6 | } from "react-native-reanimated"; 7 | import { Ionicons } from "@expo/vector-icons"; 8 | import { useTheme } from "@react-navigation/native"; 9 | 10 | const EMOJI_DATA = [ 11 | { id: "0", emoji: "❤️" }, 12 | { id: "1", emoji: "👍" }, 13 | { id: "2", emoji: "👎" }, 14 | { id: "3", emoji: "😂" }, 15 | { id: "4", emoji: "😮" }, 16 | { id: "5", emoji: "😢" }, 17 | ]; 18 | 19 | const EMOJI_HEIGHT = 22; 20 | 21 | const EmojiBar = ({ 22 | setEmojiBarHeight, 23 | }: { 24 | setEmojiBarHeight: (val: number) => void; 25 | }) => { 26 | const theme = useTheme(); 27 | return ( 28 | setEmojiBarHeight(e.nativeEvent.layout.height)} 30 | style={[styles.container, { backgroundColor: theme.colors.menu }]} 31 | > 32 | {EMOJI_DATA.map((emoji, i) => ( 33 | 38 | {emoji.emoji} 39 | 40 | ))} 41 | 45 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default EmojiBar; 56 | 57 | const styles = StyleSheet.create({ 58 | container: { 59 | flexDirection: "row", 60 | paddingVertical: 5, 61 | paddingHorizontal: 10, 62 | gap: 8, 63 | borderRadius: 200, 64 | alignItems: "center", 65 | }, 66 | emojiText: { 67 | fontSize: EMOJI_HEIGHT, 68 | textAlignVertical: "center", 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { FlatList, StyleSheet } from "react-native"; 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 4 | import { CHAT_DATA } from "@/mock/chatData"; 5 | import { CHAT_ITEM } from "@/types"; 6 | import ReactionMenu from "@/components/ReactionMenu"; 7 | import Animated, { useAnimatedRef } from "react-native-reanimated"; 8 | import Bubble from "@/components/Bubble"; 9 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 10 | 11 | const USER_ID = `a1b2c3`; 12 | 13 | const RenderItem = ({ item }: { item: CHAT_ITEM }) => { 14 | const isEven = item.userId == USER_ID; 15 | 16 | const ChatItemRef = useAnimatedRef(); 17 | const [isMenuOpen, setIsMenuOpen] = useState(false); 18 | 19 | return ( 20 | 27 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | const Chat = () => { 40 | const { bottom } = useSafeAreaInsets(); 41 | return ( 42 | 43 | } 51 | keyExtractor={(_, i) => i.toString()} 52 | /> 53 | 54 | ); 55 | }; 56 | 57 | export default Chat; 58 | 59 | const styles = StyleSheet.create({ 60 | contentContainerStyle: { 61 | gap: 20, 62 | padding: 20, 63 | }, 64 | }); 65 | -------------------------------------------------------------------------------- /components/ReactionMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useState } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import Animated from "react-native-reanimated"; 4 | import ModalWrapper from "./ReactionMenuWrapper"; 5 | import EmojiBar from "./EmojiBar"; 6 | import ChatMenu from "./ChatMenu"; 7 | import { ReactionMenuType } from "@/types"; 8 | 9 | const GAP = 20; 10 | 11 | const ReactionMenu = forwardRef( 12 | ({ isMenuOpen, setIsMenuOpen, isEven, children }, ref) => { 13 | const [state, setState] = useState({ 14 | emojiBarHeight: 0, 15 | reactionMenuHeight: 0, 16 | }); 17 | 18 | if (!isMenuOpen) { 19 | return children; 20 | } 21 | 22 | return ( 23 | <> 24 | {children} 25 | setIsMenuOpen(false)} 31 | > 32 | { 34 | e.persist(); 35 | if (isMenuOpen) { 36 | setState((prev) => ({ 37 | ...prev, 38 | reactionMenuHeight: e?.nativeEvent?.layout?.height, 39 | })); 40 | } 41 | }} 42 | style={[ 43 | styles.container, 44 | { 45 | alignItems: isEven ? "flex-start" : "flex-end", 46 | top: -state.emojiBarHeight - GAP, 47 | }, 48 | ]} 49 | > 50 | {isMenuOpen && ( 51 | 53 | setState((prev) => ({ ...prev, emojiBarHeight: e })) 54 | } 55 | /> 56 | )} 57 | {children} 58 | {isMenuOpen && ( 59 | 60 | 61 | 62 | )} 63 | 64 | 65 | 66 | ); 67 | } 68 | ); 69 | 70 | const styles = StyleSheet.create({ 71 | container: { 72 | padding: 1, 73 | gap: GAP, 74 | }, 75 | }); 76 | 77 | export default ReactionMenu; 78 | -------------------------------------------------------------------------------- /components/ChatMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, Text } from "react-native"; 3 | import { AntDesign, Entypo, Feather, Ionicons } from "@expo/vector-icons"; 4 | import Animated, { 5 | FadeInUp, 6 | FadeOutUp, 7 | LinearTransition, 8 | } from "react-native-reanimated"; 9 | import { ExtendedTheme, useTheme } from "@react-navigation/native"; 10 | 11 | const generateMenuData = (theme: ExtendedTheme) => { 12 | let color = theme.colors.text; 13 | return [ 14 | { 15 | id: "0", 16 | title: "Reply", 17 | icon: , 18 | }, 19 | { 20 | id: "1", 21 | title: "Forward", 22 | icon: , 23 | }, 24 | { 25 | id: "2", 26 | title: "Save", 27 | icon: , 28 | }, 29 | { 30 | id: "3", 31 | title: "Select", 32 | icon: , 33 | }, 34 | { 35 | id: "4", 36 | title: "Info", 37 | icon: , 38 | }, 39 | { 40 | id: "5", 41 | title: "Delete", 42 | icon: , 43 | }, 44 | ]; 45 | }; 46 | 47 | const ChatMenu = () => { 48 | const theme = useTheme(); 49 | const MENU_DATA = generateMenuData(theme); 50 | return ( 51 | 57 | {MENU_DATA.map((menu, i) => ( 58 | 67 | {menu.icon} 68 | 69 | {menu.title} 70 | 71 | 72 | ))} 73 | 74 | ); 75 | }; 76 | 77 | export default ChatMenu; 78 | 79 | const styles = StyleSheet.create({ 80 | container: { 81 | minWidth: 180, 82 | paddingVertical: 12, 83 | paddingHorizontal: 15, 84 | gap: 20, 85 | borderRadius: 12, 86 | justifyContent: "center", 87 | overflow: "hidden", 88 | }, 89 | menuItem: { 90 | gap: 20, 91 | flexDirection: "row", 92 | alignContent: "center", 93 | }, 94 | textStyle: { 95 | fontSize: 15, 96 | lineHeight: 20, 97 | }, 98 | }); 99 | -------------------------------------------------------------------------------- /components/Bubble.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Text, View } from "react-native"; 2 | import React from "react"; 3 | import { useTheme } from "@react-navigation/native"; 4 | import RenderImage from "./RenderImage"; 5 | import Button from "./Button"; 6 | import { BubbleType } from "@/types"; 7 | 8 | const Bubble = ({ setIsMenuOpen, isEven, item }: BubbleType) => { 9 | const theme = useTheme(); 10 | return ( 11 | 69 | ); 70 | }; 71 | 72 | export default Bubble; 73 | 74 | const styles = StyleSheet.create({ 75 | chatBubble: { 76 | backgroundColor: "transparent", 77 | minHeight: 40, 78 | maxWidth: "70%", 79 | borderRadius: 12, 80 | justifyContent: "flex-start", 81 | padding: 2, 82 | }, 83 | chatText: { 84 | fontSize: 15, 85 | paddingVertical: 5, 86 | paddingHorizontal: 10, 87 | fontWeight: "500", 88 | }, 89 | imageStyle: { 90 | borderRadius: 10, 91 | }, 92 | chatTail: { 93 | position: "absolute", 94 | width: 0, 95 | height: 0, 96 | backgroundColor: "transparent", 97 | borderStyle: "solid", 98 | borderLeftWidth: 10, 99 | borderRightWidth: 10, 100 | borderBottomWidth: 40, 101 | borderLeftColor: "transparent", 102 | borderRightColor: "transparent", 103 | transform: [{ rotateX: "110deg" }], 104 | marginTop: -13, 105 | zIndex: -1, 106 | }, 107 | rections: { 108 | position: "absolute", 109 | bottom: 3, 110 | minWidth: 35, 111 | width: 35, 112 | height: 25, 113 | borderRadius: 35, 114 | aspectRatio: 1, 115 | justifyContent: "center", 116 | alignItems: "center", 117 | zIndex: 1, 118 | transform: [{ translateY: 20 }], 119 | }, 120 | emojiStyle: { 121 | fontSize: 12, 122 | }, 123 | }); 124 | -------------------------------------------------------------------------------- /components/ReactionMenuWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import { 3 | View, 4 | Modal, 5 | StyleSheet, 6 | TouchableWithoutFeedback, 7 | useWindowDimensions, 8 | Platform, 9 | } from "react-native"; 10 | import Animated, { 11 | measure, 12 | useAnimatedStyle, 13 | useSharedValue, 14 | withTiming, 15 | useAnimatedReaction, 16 | } from "react-native-reanimated"; 17 | import { BlurView } from "expo-blur"; 18 | import { useHeaderHeight } from "@react-navigation/elements"; 19 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 20 | import { ModalWrapperProps } from "@/types"; 21 | 22 | const GAP = 20; 23 | 24 | const ModalWrapper = forwardRef( 25 | ( 26 | { isVisible, onClose, children, emojiBarHeight, reactionMenuHeight }, 27 | ref 28 | ) => { 29 | const { bottom } = useSafeAreaInsets(); 30 | const EXTRA_SPACE = Platform.select({ 31 | ios: bottom, 32 | default: 20, 33 | }); 34 | const headerHeight = useHeaderHeight(); 35 | const sharedModalPosition = useSharedValue({ 36 | translateY: 0, 37 | translateX: 0, 38 | width: 0, 39 | height: 0, 40 | }); 41 | const topPosition = useSharedValue(0); 42 | const { height: HEIGHT } = useWindowDimensions(); 43 | 44 | useAnimatedReaction( 45 | () => { 46 | const measured = measure(ref); 47 | return measured; 48 | }, 49 | (currentValue, previousValue) => { 50 | if (currentValue !== previousValue) { 51 | if (currentValue !== null) { 52 | const { width, height, pageX, pageY } = currentValue; 53 | sharedModalPosition.value = { 54 | translateY: pageY, 55 | translateX: pageX, 56 | width, 57 | height, 58 | }; 59 | const top = HEIGHT - reactionMenuHeight; 60 | 61 | if (pageY - emojiBarHeight - GAP < headerHeight) { 62 | topPosition.value = Math.abs( 63 | pageY - (headerHeight + emojiBarHeight + GAP) 64 | ); 65 | } 66 | if (pageY + emojiBarHeight + GAP + EXTRA_SPACE > top) { 67 | topPosition.value = -( 68 | pageY - 69 | top + 70 | emojiBarHeight + 71 | GAP + 72 | EXTRA_SPACE 73 | ); 74 | } 75 | } 76 | } 77 | } 78 | ); 79 | 80 | const animatedPositionStyle = useAnimatedStyle(() => { 81 | return { 82 | width: sharedModalPosition.value.width, 83 | height: sharedModalPosition.value.height, 84 | transform: [ 85 | { 86 | translateX: sharedModalPosition.value.translateX, 87 | }, 88 | { 89 | translateY: sharedModalPosition.value.translateY, 90 | }, 91 | { 92 | translateY: withTiming(topPosition.value), 93 | }, 94 | ], 95 | }; 96 | }); 97 | 98 | return ( 99 | 106 | 107 | {Platform.OS === "android" ? ( 108 | 109 | ) : ( 110 | 111 | )} 112 | 113 | 114 | 115 | {children} 116 | 117 | 118 | ); 119 | } 120 | ); 121 | 122 | const styles = StyleSheet.create({ 123 | overlay: { 124 | flex: 1, 125 | }, 126 | modalContent: { 127 | position: "absolute", 128 | zIndex: 10, 129 | gap: 10, 130 | cursor: "auto", 131 | }, 132 | bg: { 133 | backgroundColor: "rgba(20,20,20,0.8)", 134 | }, 135 | }); 136 | 137 | export default ModalWrapper; 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reaction Menu Demo 2 | 3 | This project demonstrates reaction menu for Android and iOS, inspired by the reaction feature in the [Signal app](https://signal.org/). Built using [Expo](https://expo.dev/) and [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/), this example project showcases how to implement smooth, interactive animations for a reaction menu that can be used in messaging apps. 4 | 5 | ## Demo 6 | 7 | Check out the reaction menu in action 👇: 8 | 9 | | Android | iOS | 10 | |--------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| 11 | |