How to make a modal dialog in React Native

By the LumioUI team · Updated

A modal is a panel that sits above the screen and asks for one decision, over a dimmed backdrop. In React Native, render React Native's Modal with transparent and no built-in animation, animate the backdrop and panel yourself with Reanimated, and keep it mounted until the exit animation finishes. The LumioUI Dialog does this: the backdrop darkens first, the panel rises, and on close it fades out in place.

Live demo of the LumioUI Dialog component. It's interactive, so try it.

How a modal works

  1. Above everything. React Native's Modal renders in its own window, over headers and tab bars. Set transparent so your backdrop shows, and statusBarTranslucent on Android so it covers the status bar.
  2. Two layers. The backdrop fades; the panel scales or rises. Animate them separately so the backdrop leads in and the panel leads out.
  3. Exit animations need a delay. Modal drops its content as soon as visible is false. Keep a mounted state that lags behind open by the exit duration.
  4. Ways to close. A tap on the backdrop, the Android back button (onRequestClose) and a Cancel button should all close it.
  5. Accessibility. accessibilityViewIsModal keeps VoiceOver inside the panel; give the title the header role and label the backdrop "Close".
TSX ConfirmModal.tsx
import * as React from "react";
import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut, ZoomIn, ZoomOut } from "react-native-reanimated";

export function ConfirmModal({ open, onClose, onConfirm }) {
  // RN's Modal removes its content the moment `visible` turns false, so
  // exit animations never run. Keep it mounted until the panel has left.
  const [mounted, setMounted] = React.useState(open);
  React.useEffect(() => {
    if (open) setMounted(true);
    else if (mounted) {
      const t = setTimeout(() => setMounted(false), 160);
      return () => clearTimeout(t);
    }
  }, [open]);

  return (
    <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
      {open && (
        <View style={styles.root}>
          <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)} style={styles.backdrop}>
            <Pressable style={StyleSheet.absoluteFill} onPress={onClose} accessibilityLabel="Close" />
          </Animated.View>
          <Animated.View entering={ZoomIn.duration(200)} exiting={ZoomOut.duration(120)}
            style={styles.panel} accessibilityViewIsModal>
            <Text accessibilityRole="header" style={styles.title}>Delete this photo?</Text>
            <Text>It will be removed from all your albums.</Text>
            <View style={styles.row}>
              <Pressable onPress={onClose} accessibilityRole="button"><Text>Cancel</Text></Pressable>
              <Pressable onPress={onConfirm} accessibilityRole="button"><Text>Delete</Text></Pressable>
            </View>
          </Animated.View>
        </View>
      )}
    </Modal>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, justifyContent: "center", padding: 24 },
  backdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundColor: "rgba(0,0,0,0.4)" },
  panel: { backgroundColor: "#fff", borderRadius: 20, padding: 20, gap: 8 },
  title: { fontSize: 17, fontWeight: "600" },
  row: { flexDirection: "row", justifyContent: "flex-end", gap: 16, marginTop: 12 },
});

For a choice from a list rather than a yes or no, a sheet from the bottom usually reads better on phones: see how to make a bottom sheet.

The quick way: LumioUI Dialog

The Dialog handles mounting, motion, reduced motion, haptics, back-button and backdrop dismissal, and screen readers. It's free and runs in Expo Go.

TSX DeletePhoto.tsx
import * as React from "react";
import { Button } from "@/components/ui/button/button";
import {
  Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog/dialog";

export function DeletePhoto({ onDelete }) {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Button variant="destructive" onPress={() => setOpen(true)}>Delete photo</Button>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Delete this photo?</DialogTitle>
            <DialogDescription>It will be removed from all your albums.</DialogDescription>
          </DialogHeader>
          <DialogFooter>
            <DialogClose><Button variant="outline">Cancel</Button></DialogClose>
            {/* DialogClose runs the button's onPress, then closes */}
            <DialogClose><Button variant="destructive" onPress={onDelete}>Delete</Button></DialogClose>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}
  • open and onOpenChange control it; or use defaultOpen and let it manage itself.
  • DialogClose wraps any button: it runs the button's own onPress, then closes.
  • DialogContent material="glass" switches to liquid glass for dialogs over photos or video.
  • For a modal with several steps that resizes to fit each one, see Morphing Modal (Pro).

Add it to your app

LumioUI's CLI copies the component's source into your project, so it's yours to read and change. Run init once per project:

npx @lumioui/cli init
npx @lumioui/cli add dialog

It works in Expo (SDK 54+) and bare React Native, with Reanimated 4 and NativeWind. See the installation guide and the full Dialog API.

More React Native guides