This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

Create a modal

Edit page

In this tutorial, learn how to create an emoji picker with the Expo UI BottomSheet component and show the selected emoji as a sticker in a React Native app.


A modal presents content above the rest of the app. In general, modals are used to draw a user's attention toward critical information or guide them to take action. A bottom sheet is a type of modal that slides up from the bottom of the screen.

In this chapter, we'll create an emoji picker with the BottomSheet component from Expo UI. On Android and iOS, it displays the platform's native bottom sheet. On web, it displays a sheet at the bottom of the page.

Watch: Creating a modal in your universal Expo app
Watch: Creating a modal in your universal Expo app

Build a modal component using React Native's Modal API to display an emoji picker and handle user interactions.


1

Declare a state variable to show buttons

Before implementing the bottom sheet, we are going to add three new buttons. These buttons are visible after the user picks an image from the media library or uses the placeholder image. One of these buttons will open the emoji picker.

In src/app/(tabs)/index.tsx:

  1. Declare a boolean state variable, showAppOptions, to show or hide the buttons that open the emoji picker, alongside a few other options. When the app screen loads, we'll set it to false so the options are not shown before picking an image. When the user picks an image or uses the placeholder image, we'll set it to true.
  2. Update the pickImageAsync() function to set the value of showAppOptions to true after the user picks an image.
  3. In the onPress prop of the button with no theme, replace the alert() with a function that sets showAppOptions to true.
src/app/(tabs)/index.tsx
import { useState } from 'react'; import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View /> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, });

In the above snippet, we're rendering the Button component based on the value of showAppOptions and moving the buttons in the ternary operator block. When the value of showAppOptions is true, render an empty <View> component. We'll address this state in the next step.

2

Add buttons

Let's break down the layout of the option buttons we'll implement in this chapter. The design looks like this:

It contains a parent <View> with three buttons aligned in a row. The button in the middle with the plus icon (+) will open the emoji picker and is styled differently than the other two buttons.

Like the Button component in the third chapter, the option buttons use Expo UI on Android and iOS, and React Native components on web. Each button has one file for each platform.

Inside the src/components directory, create three files for the circle button:

src/components/circle-button.android.tsx
import { StyleSheet, View } from 'react-native'; import { Host, FloatingActionButton, Icon } from '@expo/ui/jetpack-compose'; import { clip, Shapes, size } from '@expo/ui/jetpack-compose/modifiers'; type Props = { onPress: () => void; }; export default function CircleButton({ onPress }: Props) { return ( <View style={styles.circleButtonContainer}> <Host matchContents> <FloatingActionButton containerColor="#ffd33d" onClick={onPress} modifiers={[size(84, 84), clip(Shapes.Circle)]}> <FloatingActionButton.Icon> <Icon source={require('@expo/material-symbols/add.xml')} size={38} tint="#25292e" /> </FloatingActionButton.Icon> </FloatingActionButton> </Host> </View> ); } const styles = StyleSheet.create({ circleButtonContainer: { marginHorizontal: 60, }, });

Let's learn what the above code does:

  • On Android, FloatingActionButton displays a Material 3 floating action button. The size and clip modifiers make it a circle of 84 dp, and Icon displays the add.xml icon from @expo/material-symbols.
  • On iOS, Button uses the plain style, and its label is an SF Symbols Image. The frame and background modifiers draw a yellow circle of 84 points around the icon.
  • On web, circle-button.tsx uses Pressable and SymbolView, like button.tsx in the third chapter.

The other two buttons display an icon above a text label. Inside the src/components directory, create three files for the icon button. This component accepts three props:

  • icon: an icon for each platform. Android uses an XML icon from @expo/material-symbols, iOS uses an SF Symbols name, and web uses a Material Symbols name.
  • label: the text label displayed on the button.
  • onPress: this function invokes when the user presses the button.
src/components/icon-button.android.tsx
import type { ImageSourcePropType } from 'react-native'; import { Host, TextButton, Column, Icon, Text } from '@expo/ui/jetpack-compose'; import type { AndroidSymbol, SFSymbol } from 'expo-symbols'; type Props = { icon: { android: ImageSourcePropType; ios: SFSymbol; web: AndroidSymbol; }; label: string; onPress: () => void; }; export default function IconButton({ icon, label, onPress }: Props) { return ( <Host matchContents> <TextButton onClick={onPress} colors={{ contentColor: '#fff' }}> <Column horizontalAlignment="center" verticalArrangement={{ spacedBy: 12 }}> <Icon source={icon.android} size={24} tint="#fff" /> <Text color="#fff">{label}</Text> </Column> </TextButton> </Host> ); }

On Android, a Column places the Icon above the Text. On iOS, a VStack does the same. All three files accept the same props, like the Button files in the third chapter.

The src/components directory now contains three files for each button:

src
 components
  button.android.tsxUsed on Android
  button.ios.tsxUsed on iOS
  button.tsxUsed on web
  circle-button.android.tsxUsed on Android
  circle-button.ios.tsxUsed on iOS
  circle-button.tsxUsed on web
  icon-button.android.tsxUsed on Android
  icon-button.ios.tsxUsed on iOS
  icon-button.tsxUsed on web
  image-viewer.tsx

Inside src/app/(tabs)/index.tsx:

  1. Import the CircleButton and IconButton components to display them.
  2. Add three placeholder functions for these buttons. The onReset() function invokes when the user presses the reset button, causing the image picker button to appear again. We'll add the functionality for the other two functions later.
  3. Pass an icon for each platform to the icon prop of each IconButton.
src/app/(tabs)/index.tsx
import { useState } from 'react'; import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; import IconButton from '@/components/icon-button'; import CircleButton from '@/components/circle-button'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { // we will implement this later }; const onSaveImageAsync = async () => { // we will implement this later }; return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View style={styles.footerContainer}> <View style={styles.optionsRow}> <IconButton icon={{ android: require('@expo/material-symbols/refresh.xml'), ios: 'arrow.counterclockwise', web: 'refresh', }} label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon={{ android: require('@expo/material-symbols/download.xml'), ios: 'square.and.arrow.down', web: 'download', }} label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

The option buttons use the same footerContainer style as the Choose a photo and Use this photo buttons. They appear in the same place on the screen, above the tab bar.

Let's take a look at our app on Android, iOS, and web:

3

Create an emoji picker with a bottom sheet

The bottom sheet allows the user to choose an emoji from a list of available emoji. Create an emoji-picker.tsx file inside the src/components directory. This component accepts three props:

  • isVisible: a boolean to determine whether the bottom sheet is visible.
  • onClose: a function to close the bottom sheet.
  • children: used later to display a list of emoji.
src/components/emoji-picker.tsx
import { PropsWithChildren } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { BottomSheet, RNHostView } from '@expo/ui'; type Props = PropsWithChildren<{ isVisible: boolean; onClose: () => void; }>; export default function EmojiPicker({ isVisible, children, onClose }: Props) { return ( <BottomSheet isPresented={isVisible} onDismiss={onClose} snapPoints={['half']} containerColor="#25292e"> <RNHostView> <View> <Text style={styles.title}>Choose a sticker</Text> {children} </View> </RNHostView> </BottomSheet> ); } const styles = StyleSheet.create({ title: { color: '#fff', fontSize: 16, marginBottom: 20, }, });

Let's learn what the above code does:

  • The BottomSheet component comes from @expo/ui, which the default template already includes. On Android, it displays a Material 3 bottom sheet. On iOS, it displays a SwiftUI sheet. On web, it displays a sheet at the bottom of the page.
  • Its isPresented prop takes the value of isVisible and controls whether the bottom sheet is open or closed.
  • Its onDismiss prop calls onClose when the user closes the bottom sheet. The user can swipe the bottom sheet down or tap outside it. On Android, the back button also closes it. On web, the Esc key also closes it.
  • Its snapPoints prop sets the heights where the bottom sheet can rest. The 'half' value opens it at about half of the screen height.
  • Its containerColor prop sets the background color of the bottom sheet to match the app's background.
  • The RNHostView component displays React Native views, such as <View> and <Text>, inside the bottom sheet.
What is RNHostView?

On Android and iOS, BottomSheet is a native view from Jetpack Compose and SwiftUI. To display React Native views inside it, wrap them in RNHostView. It works in the opposite direction of Host, which we used in the third chapter to display Expo UI components inside React Native views. BottomSheet creates its own Host, so we don't wrap it in one. For more information, see RNHostView.

Now, let's modify the src/app/(tabs)/index.tsx:

  1. Import the <EmojiPicker> component.
  2. Create an isModalVisible state variable with the useState hook. Its default value is false, which hides the bottom sheet until the user presses the button to open it.
  3. Replace the comment in the onAddSticker() function to update the isModalVisible variable to true when the user presses the button. This will open the emoji picker.
  4. Create the onModalClose() function to update the isModalVisible state variable.
  5. Place the <EmojiPicker> component at the bottom of the Index component.
src/app/(tabs)/index.tsx
import { useState } from 'react'; import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; import IconButton from '@/components/icon-button'; import CircleButton from '@/components/circle-button'; import EmojiPicker from '@/components/emoji-picker'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { setIsModalVisible(true); }; const onModalClose = () => { setIsModalVisible(false); }; const onSaveImageAsync = async () => { // we will implement this later }; return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View style={styles.footerContainer}> <View style={styles.optionsRow}> <IconButton icon={{ android: require('@expo/material-symbols/refresh.xml'), ios: 'arrow.counterclockwise', web: 'refresh', }} label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon={{ android: require('@expo/material-symbols/download.xml'), ios: 'square.and.arrow.down', web: 'download', }} label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}> {/* Emoji list component will go here */} </EmojiPicker> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

Here is the result after this step:

4

Display a list of emoji

Let's add a grid of emoji to the bottom sheet's content. We'll use the <FlatList> component from React Native for it.

Create an emoji-list.tsx file inside the src/components directory and add the following code:

src/components/emoji-list.tsx
import { FlatList, Pressable, StyleSheet } from 'react-native'; import { Image, type ImageSource } from 'expo-image'; type Props = { onSelect: (image: ImageSource) => void; onCloseModal: () => void; }; const emoji: ImageSource[] = [ require('@/assets/images/emoji1.png'), require('@/assets/images/emoji2.png'), require('@/assets/images/emoji3.png'), require('@/assets/images/emoji4.png'), require('@/assets/images/emoji5.png'), require('@/assets/images/emoji6.png'), ]; export default function EmojiList({ onSelect, onCloseModal }: Props) { return ( <FlatList data={emoji} numColumns={3} columnWrapperStyle={styles.row} renderItem={({ item }) => ( <Pressable onPress={() => { onSelect(item); onCloseModal(); }}> <Image source={item} style={styles.image} /> </Pressable> )} /> ); } const styles = StyleSheet.create({ row: { justifyContent: 'space-around', marginBottom: 20, }, image: { width: 100, height: 100, }, });

Let's learn what the above code does:

  • The emoji array holds the emoji images. It is outside the component because its value never changes.
  • The <FlatList> component takes the emoji array as the value of the data prop. The renderItem prop takes each item from the data and returns an Image component, wrapped by a <Pressable>. When the user presses an emoji, onSelect receives it, and onCloseModal closes the bottom sheet.
  • The numColumns prop displays the list as a grid with three columns. The columnWrapperStyle prop styles each row of the grid.

Now, update the src/app/(tabs)/index.tsx to import the <EmojiList> component and replace the comments inside the <EmojiPicker> component with the following code snippet:

src/app/(tabs)/index.tsx
import { useState } from 'react'; import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import type { ImageSource } from 'expo-image'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; import IconButton from '@/components/icon-button'; import CircleButton from '@/components/circle-button'; import EmojiPicker from '@/components/emoji-picker'; import EmojiList from '@/components/emoji-list'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const [pickedEmoji, setPickedEmoji] = useState<ImageSource | undefined>(undefined); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { setIsModalVisible(true); }; const onModalClose = () => { setIsModalVisible(false); }; const onSaveImageAsync = async () => { // we will implement this later }; return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View style={styles.footerContainer}> <View style={styles.optionsRow}> <IconButton icon={{ android: require('@expo/material-symbols/refresh.xml'), ios: 'arrow.counterclockwise', web: 'refresh', }} label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon={{ android: require('@expo/material-symbols/download.xml'), ios: 'square.and.arrow.down', web: 'download', }} label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}> <EmojiList onSelect={setPickedEmoji} onCloseModal={onModalClose} /> </EmojiPicker> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

In the EmojiList component, the onSelect prop selects the emoji and after selecting it, the onCloseModal closes the bottom sheet.

Let's take a look at our app on Android, iOS, and web:

5

Display the selected emoji

Now, we'll put the emoji sticker on the image. Create a new file in the src/components directory and call it emoji-sticker.tsx. Then, add the following code:

src/components/emoji-sticker.tsx
import { View } from 'react-native'; import { Image, type ImageSource } from 'expo-image'; type Props = { imageSize: number; stickerSource: ImageSource; }; export default function EmojiSticker({ imageSize, stickerSource }: Props) { return ( <View style={{ top: -350 }}> <Image source={stickerSource} style={{ width: imageSize, height: imageSize }} /> </View> ); }

This component receives two props:

  • imageSize: a value defined inside the Index component. We will use this value in the next chapter to scale the image's size when tapped.
  • stickerSource: the source of the selected emoji image.

Import this component in the src/app/(tabs)/index.tsx file and update the Index component to display the emoji sticker on the image. We will check if the pickedEmoji state is not undefined:

src/app/(tabs)/index.tsx
import { useState } from 'react'; import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as ImagePicker from 'expo-image-picker'; import type { ImageSource } from 'expo-image'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; import IconButton from '@/components/icon-button'; import CircleButton from '@/components/circle-button'; import EmojiPicker from '@/components/emoji-picker'; import EmojiList from '@/components/emoji-list'; import EmojiSticker from '@/components/emoji-sticker'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const [pickedEmoji, setPickedEmoji] = useState<ImageSource | undefined>(undefined); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { setIsModalVisible(true); }; const onModalClose = () => { setIsModalVisible(false); }; const onSaveImageAsync = async () => { // we will implement this later }; return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> {pickedEmoji && <EmojiSticker imageSize={40} stickerSource={pickedEmoji} />} </View> {showAppOptions ? ( <View style={styles.footerContainer}> <View style={styles.optionsRow}> <IconButton icon={{ android: require('@expo/material-symbols/refresh.xml'), ios: 'arrow.counterclockwise', web: 'refresh', }} label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon={{ android: require('@expo/material-symbols/download.xml'), ios: 'square.and.arrow.down', web: 'download', }} label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}> <EmojiList onSelect={setPickedEmoji} onCloseModal={onModalClose} /> </EmojiPicker> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

Let's take a look at our app on Android, iOS, and web:

Summary

Chapter 5: Create a modal

We've successfully created an emoji picker with a bottom sheet and native buttons from Expo UI, and implemented the logic to display the selected emoji over the image.

In the next chapter, let's add user interactions with gestures to drag the emoji and scale the size by tapping it.

Next: Chapter 6: Add gestures