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

Build a screen

Edit page

In this tutorial, learn how to use components such as React Native's Pressable, Expo Image, and Expo UI to build a screen.


In this chapter, we'll create the first screen of the StickerSmash app.

The screen above displays an image and two buttons. The app user can select an image using one of the two buttons. The first button allows the user to select an image from their device. The second button allows the user to continue with a default image provided by the app.

Once the user selects an image, they can add a sticker to it. So, let's start creating this screen.

Watch: Building a screen in your universal Expo app
Watch: Building a screen in your universal Expo app

Build the StickerSmash app's first screen using Pressable, Expo Image, and other core components to create an image picker layout.


1

Break down the screen

Before we build this screen by writing code, let's break it down into some essential elements.

There are two essential elements:

  • A large image in the top half of the screen
  • Two buttons in the bottom half of the screen

The first button contains multiple components. It has a yellow background. It contains an icon and a text component inside a row.

Now that we've broken down the UI into smaller chunks, we're ready to start coding.

2

Display the image

We'll use expo-image library to display the image in the app. It provides a cross-platform <Image> component to load and render an image. It is already included in the default project template we're using.

The Image component takes the source of an image as its value. The source can be either a static asset or a URL. For example, the source required from assets/images directory is static. It can also come from Network as a uri property.

To use the Image component in src/app/(tabs)/index.tsx file:

  1. Import Image from the expo-image library.
  2. Create a PlaceholderImage variable to use assets/images/background-image.png file as the source prop on the Image component.
  3. Replace the outer View with SafeAreaView from react-native-safe-area-context. Native tabs don't add a header, so SafeAreaView keeps the screen content below the status bar on Android and iOS. The default template includes this library.
  4. Add a paddingTop to the image container with Platform.select(). On web, the tab list sits at the top of the page, so the image needs more space there.
src/app/(tabs)/index.tsx
import { Platform, View, StyleSheet } from 'react-native'; import { Image } from 'expo-image'; import { SafeAreaView } from 'react-native-safe-area-context'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <Image source={PlaceholderImage} style={styles.image} /> </View> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, image: { width: 320, height: 440, borderRadius: 18, }, });

3

Divide components into files

Let's divide the code into multiple files as we add more components to this screen. Throughout this tutorial, we'll use the components directory to create custom components.

  1. Create a components directory inside src, and inside it, create the image-viewer.tsx file.
  2. Move the code to display the image in this file along with the image styles.
src/components/image-viewer.tsx
import { StyleSheet } from 'react-native'; import { Image, type ImageSource } from 'expo-image'; type Props = { imgSource: ImageSource; }; export default function ImageViewer({ imgSource }: Props) { return <Image source={imgSource} style={styles.image} />; } const styles = StyleSheet.create({ image: { width: 320, height: 440, borderRadius: 18, }, });

Import ImageViewer and use it in the src/app/(tabs)/index.tsx:

src/app/(tabs)/index.tsx
import { Platform, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import ImageViewer from '@/components/image-viewer'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} /> </View> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, paddingTop: Platform.select({ web: 96, default: 20 }), }, });
What is the @ in import statement?

The @ symbol is a custom path alias for importing custom components and other modules instead of relative paths. Expo CLI automatically configures it in tsconfig.json.

4

Create buttons using Pressable

React Native includes a few different components for handling touch events, but <Pressable> is recommended for its flexibility. It can detect single taps, long presses, trigger separate events when the button is pushed in and released, and more.

In the design, there are two buttons we need to create. Each has a different style and label. Let's start by creating a reusable component for these buttons. Create a button.tsx file inside the src/components directory with the following code:

src/components/button.tsx
import { StyleSheet, View, Pressable, Text } from 'react-native'; type Props = { label: string; onPress?: () => void; }; export default function Button({ label, onPress }: Props) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={onPress}> <Text style={styles.buttonLabel}>{label}</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, alignItems: 'center', justifyContent: 'center', }, button: { height: 48, paddingHorizontal: 20, borderRadius: 24, alignItems: 'center', justifyContent: 'center', flexDirection: 'row', gap: 8, }, buttonLabel: { color: '#fff', fontSize: 16, }, });

The onPress prop lets the screen decide what happens when the user presses the button. Let's import this component into the src/app/(tabs)/index.tsx file. Pass each button an onPress function that calls alert(). Then, add styles for the <View> that encapsulates the buttons:

src/app/(tabs)/index.tsx
import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} /> </View> <View style={styles.footerContainer}> <Button label="Choose a photo" onPress={() => alert('You pressed a button.')} /> <Button label="Use this photo" onPress={() => alert('You pressed a button.')} /> </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', }, });

The app displays an alert when the user taps any of the buttons on the screen. Let's take a look at our app on Android, iOS, and web:

The second button with the label "Use this photo" resembles the actual button from the design. However, the first button needs more styling to match the design.

5

Enhance the reusable button component

The "Choose a photo" button requires different styling than the "Use this photo" button. Let's add a theme prop to apply a primary theme to it. This button also has an icon before the label. We will use the SymbolView component from expo-symbols to display it. On Android and web, SymbolView displays a Material Symbols icon. On iOS, it displays an SF Symbols icon. The default template already includes expo-symbols.

Modify src/components/button.tsx to add the following code snippet:

src/components/button.tsx
import { StyleSheet, View, Pressable, Text } from 'react-native'; import { SymbolView } from 'expo-symbols'; type Props = { label: string; theme?: 'primary'; onPress?: () => void; }; export default function Button({ label, theme, onPress }: Props) { if (theme === 'primary') { return ( <View style={styles.buttonContainer}> <Pressable style={[styles.button, styles.primaryButton]} onPress={onPress}> <SymbolView name={{ ios: 'photo', android: 'image', web: 'image' }} size={18} tintColor="#25292e" /> <Text style={[styles.buttonLabel, { color: '#25292e' }]}>{label}</Text> </Pressable> </View> ); } return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={onPress}> <Text style={styles.buttonLabel}>{label}</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, alignItems: 'center', justifyContent: 'center', }, button: { height: 48, paddingHorizontal: 20, borderRadius: 24, alignItems: 'center', justifyContent: 'center', flexDirection: 'row', gap: 8, }, primaryButton: { backgroundColor: '#ffd33d', }, buttonLabel: { color: '#fff', fontSize: 16, }, });

Let's learn what the above code does:

  • The primary theme button passes an array to the style prop of <Pressable>. React Native applies the styles in order, so the backgroundColor from styles.primaryButton applies only to the primary button.
  • The label of the primary button uses an inline style, an object passed directly in the style prop. It changes the label color to #25292e and overrides the color from styles.buttonLabel.
  • The name prop of SymbolView takes an icon name for each platform: image for Android and web, and photo for iOS.

Now, modify the src/app/(tabs)/index.tsx file to use the theme="primary" prop on the first button.

src/app/(tabs)/index.tsx
import { Platform, View, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Button from '@/components/button'; import ImageViewer from '@/components/image-viewer'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} /> </View> <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={() => alert('You pressed a button.')} /> <Button label="Use this photo" onPress={() => alert('You pressed a button.')} /> </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', }, });

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

6

Use native buttons with Expo UI

Our button uses React Native components, so it looks the same on every platform. Expo UI provides native components: Jetpack Compose views on Android and SwiftUI views on iOS. The default template already includes Expo UI (@expo/ui). Expo UI works in Expo Go.

Let's replace the button on Android and iOS with a native button, and keep the React Native button for web. To do this, we'll use platform-specific extensions. When button.android.tsx and button.ios.tsx exist next to button.tsx, Metro uses the file for the current platform when we import @/components/button. Web still uses button.tsx. For more information, see Platform-specific extensions.

The Android button displays an icon from @expo/material-symbols, a set of Material Symbols icons for Jetpack Compose. To install it, stop the development server by pressing Ctrl + C in the terminal, then run the following command:

Terminal
- npx expo install @expo/material-symbols
- yarn expo install @expo/material-symbols
- pnpm expo install @expo/material-symbols
- bun expo install @expo/material-symbols

Keep the development server stopped until both new files exist. If you add them while the server is running, Metro can keep using button.tsx. In that case, restart the server.

Create a button.android.tsx file inside the src/components directory:

src/components/button.android.tsx
import { StyleSheet, View } from 'react-native'; import { Host, Button as ComposeButton, TextButton, Icon, Row, Text } from '@expo/ui/jetpack-compose'; type Props = { label: string; theme?: 'primary'; onPress?: () => void; }; export default function Button({ label, theme, onPress }: Props) { if (theme === 'primary') { return ( <View style={styles.buttonContainer}> <Host matchContents> <ComposeButton onClick={onPress} colors={{ containerColor: '#ffd33d', contentColor: '#25292e' }}> <Row verticalAlignment="center" horizontalArrangement={{ spacedBy: 8 }}> <Icon source={require('@expo/material-symbols/image.xml')} size={18} tint="#25292e" /> <Text color="#25292e">{label}</Text> </Row> </ComposeButton> </Host> </View> ); } return ( <View style={styles.buttonContainer}> <Host matchContents> <TextButton onClick={onPress} colors={{ contentColor: '#fff' }}> <Text color="#fff">{label}</Text> </TextButton> </Host> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, alignItems: 'center', justifyContent: 'center', }, });
What is Host?

Host is the bridge between React Native and Expo UI. Jetpack Compose components on Android and SwiftUI components on iOS render only inside a Host. The matchContents prop sizes the Host to fit its content, which is the button here. For more information, see Host for Jetpack Compose and SwiftUI.

Then, create a button.ios.tsx file inside the src/components directory:

src/components/button.ios.tsx
import { StyleSheet, View } from 'react-native'; import { Host, Button as SwiftUIButton } from '@expo/ui/swift-ui'; import { buttonStyle, controlSize, foregroundStyle, tint } from '@expo/ui/swift-ui/modifiers'; type Props = { label: string; theme?: 'primary'; onPress?: () => void; }; export default function Button({ label, theme, onPress }: Props) { if (theme === 'primary') { return ( <View style={styles.buttonContainer}> <Host matchContents> <SwiftUIButton label={label} systemImage="photo" onPress={onPress} modifiers={[ buttonStyle('borderedProminent'), controlSize('large'), tint('#ffd33d'), foregroundStyle('#25292e'), ]} /> </Host> </View> ); } return ( <View style={styles.buttonContainer}> <Host matchContents> <SwiftUIButton label={label} onPress={onPress} modifiers={[buttonStyle('borderless'), controlSize('large'), foregroundStyle('#fff')]} /> </Host> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, alignItems: 'center', justifyContent: 'center', }, });
What are modifiers?

Modifiers customize the appearance and behavior of Expo UI components, such as their style, size, and colors. Import them from @expo/ui/jetpack-compose/modifiers for Jetpack Compose and from @expo/ui/swift-ui/modifiers for SwiftUI. Then, pass them in an array to the modifiers prop of a component, as button.ios.tsx does. For more information, see modifiers for Jetpack Compose and SwiftUI.

Both files keep the same props as button.tsx, so src/app/(tabs)/index.tsx doesn't change. The src/components directory now contains three versions of the button:

src
 components
  button.android.tsxUsed on Android
  button.ios.tsxUsed on iOS
  button.tsxUsed on web
  image-viewer.tsx
Why do we keep button.tsx?

Web uses button.tsx, because there is no button.web.tsx file. TypeScript also reads the types of @/components/button from button.tsx. So all three files must accept the same props.

Start the development server again by running npx expo start. Let's take a look at our app on Android, iOS, and web:

Summary

Chapter 3: Build a screen

We've successfully built our app's first screen with Expo Image and native buttons from Expo UI.

In the next chapter, we'll add the functionality to pick an image from the device's media library.

Next: Chapter 4: Use an image picker