eoria

Overlays

Pick an overlay, mount its host, and handle stacking, dismissal and async actions.

Pick the overlay by what the user needs to do. A short confirmation should not need a draggable sheet, and a tooltip should never hold an action.

Which one to use

Need Component Behaviour
A short question or a small panel Dialog Bottom placement by default, or placement="center". The grabber is decoration, not a drag handle.
Confirm a destructive action AlertDialog Centred, with explicit actions. A mounted Cancel allows backdrop and Android back dismissal.
A form, filters or content that scrolls Sheet Draggable, with snap points and keyboard support from @gorhom/bottom-sheet.
Several actions within thumb reach ActionSheet A bottom list of actions with a separate Cancel. Built on Dialog, so it does not drag.
Details beside a trigger Popover An anchored card that can hold text and controls.
Actions for one row or object DropdownMenu An anchored menu. Selecting an item closes it by default.
A short hint for a control Tooltip Opens on long press, closes on release. String content also becomes the trigger’s accessibility hint.
Confirm that work finished Toast Call toast(). It announces the message and dismisses after four seconds by default.

Keep a failed form submission beside the form in an Alert. A toast disappears, which makes it a poor home for an error the user still needs to fix.

Mount the hosts once

Portal renders content in a root PortalHost instead of inside a clipped row or scroll view. Dialog, AlertDialog, ActionSheet, Popover, DropdownMenu, Tooltip and Toaster use that host. Sheet uses the separate host supplied by BottomSheetModalProvider.

The example app’s apps/example/src/app/_layout.tsx mounts GestureHandlerRootView, KeyboardProvider, BottomSheetModalProvider, PortalHost and Toaster. The toast host is named Toaster. Keep your Unistyles setup import before rendering any of them, and keep the app’s font-loading gate if your preset uses custom fonts.

For a sheet that opens a dialog, put PortalHost and Toaster after the whole BottomSheetModalProvider, as below. The example app currently puts them inside that provider. Gorhom appends its own host after the provider’s children, so an eoria dialog can otherwise sit behind the sheet.

app/_layout.tsx
import '@/unistyles'
import { Stack } from 'expo-router'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { KeyboardProvider } from 'react-native-keyboard-controller'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useUnistyles } from 'react-native-unistyles'
import { PortalHost } from '@/components/ui/portal'
import { Toaster } from '@/components/ui/toast'
export default function RootLayout() {
const insets = useSafeAreaInsets()
const { theme } = useUnistyles()
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<BottomSheetModalProvider>
<Stack />
</BottomSheetModalProvider>
<PortalHost />
<Toaster
offset={{ top: insets.top + theme.space[2], bottom: insets.bottom + theme.space[4] }}
/>
</KeyboardProvider>
</GestureHandlerRootView>
)
}

Expo Router supplies the safe-area context used here. With a standalone navigator, provide that context at the app root too. Keep the hosts mounted across route changes.

React context from the trigger’s branch does not automatically cross these portals. The eoria overlays re-provide their own context. Pass application data through props, put its provider above the host, or provide it again inside the overlay.

A sheet opening a dialog

Keep both open states in the screen. Render the dialog beside the sheet so closing the sheet cannot leave a dialog owned by unmounted sheet content. With the host order above, the dialog covers the sheet and closing it reveals the sheet again.

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle,
} from '@/components/ui/dialog'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
export function DeliveryOptions() {
const [sheetOpen, setSheetOpen] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
return (
<>
<Button onPress={() => setSheetOpen(true)}>Delivery options</Button>
<Sheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpen(open)
if (!open) setDialogOpen(false)
}}
>
<SheetContent>
<SheetTitle>Delivery</SheetTitle>
<Button variant="outline" onPress={() => setDialogOpen(true)}>
How delivery works
</Button>
</SheetContent>
</Sheet>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen} placement="center">
<DialogContent>
<DialogTitle>Delivery times</DialogTitle>
<DialogDescription>Orders leave the warehouse within two working days.</DialogDescription>
<DialogClose asChild>
<Button>Got it</Button>
</DialogClose>
</DialogContent>
</Dialog>
</>
)
}

Portal entries draw in mount order. A newly opened dialog appears above earlier eoria entries, but this is not a global overlay manager. For a menu opening a confirmation, close the menu and keep the confirmation outside its content. For several nested overlays, let only the top one handle back.

Dismissal is part of the flow

Component Backdrop Android back Drag or back gesture
Dialog and ActionSheet Closes unless content has dismissable={false} Still closes when dismissable is false No drag dismissal. A route’s edge-back gesture belongs to the navigator.
AlertDialog Closes only with a mounted AlertDialogCancel Same rule, otherwise consumes the press No drag dismissal.
Sheet Closes unless SheetContent has dismissable={false} Closes the top sheet, the one presented last, even when dismissable is false Pan down closes when dismissable. This is separate from a route’s back gesture.
Popover Closes by default Closes by default No drag dismissal. dismissable={false} removes both dismissal handlers.
DropdownMenu Closes Closes No drag dismissal.
Tooltip No backdrop No back handler Release the trigger to close.
Toast No backdrop No back handler Tap the toast or wait for its timer. toast.dismiss() closes all toasts.

Put cleanup in onOpenChange, since backdrop and back dismissal do not call a Cancel button’s onPress. Disabling AlertDialogCancel does not remove it, so it still permits backdrop and Android back requests. To reject those requests while saving, guard onOpenChange as in the delete example below.

An overlay does not prevent the navigator from removing its route. See Navigation for focused Android back handling and route gestures.

The keyboard inside a sheet

Gorhom moves a sheet for the keyboard only when the focused field is its BottomSheetTextInput. SheetContent provides that input to the eoria fields through TextInputContext, so Input, Textarea, InputGroup, PasswordInput and SearchBar work as written. A footer passed to SheetContent stays above the keyboard. The Sheet page covers keyboardBehavior, keyboardBlurBehavior and android_keyboardInputMode.

import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
export function AddressSheet({
open, onOpenChange, address, onAddressChange, onSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
address: string
onAddressChange: (address: string) => void
onSave: () => void
}) {
const insets = useSafeAreaInsets()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
scroll
topInset={insets.top}
footer={
<Button width="full" onPress={onSave}>
Save address
</Button>
}
>
<SheetTitle>Delivery address</SheetTitle>
<Input
accessibilityLabel="Street address"
value={address}
onChangeText={onAddressChange}
/>
</SheetContent>
</Sheet>
)
}

A plain React Native TextInput inside a sheet still needs BottomSheetTextInput by hand.

For a full-screen form, use KeyboardScrollView instead. Mounting KeyboardProvider alone does not give a plain input Gorhom’s focus handling.

A row menu, a confirmation and a toast

Keep the selected row above the list. The confirmation must survive the menu closing and the deleted row disappearing. This example takes the deletion function from the app and removes a row only after that promise resolves.

import { useEffect, useRef, useState } from 'react'
import { AccessibilityInfo, FlatList } from 'react-native'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Item, ItemContent, ItemTitle, ItemTrailing } from '@/components/ui/item'
import { toast } from '@/components/ui/toast'
type Note = { id: string; title: string }
export function Notes({
initialNotes, onDelete,
}: {
initialNotes: Note[]
onDelete: (id: string) => Promise<void>
}) {
const [notes, setNotes] = useState(initialNotes)
const [selected, setSelected] = useState<Note | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const pending = useRef(false)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
async function remove() {
if (!selected || pending.current) return
const note = selected
pending.current = true
setBusy(true)
setError(undefined)
try {
await onDelete(note.id)
if (!mounted.current) return
setNotes((current) => current.filter((row) => row.id !== note.id))
setSelected(null)
toast({ title: 'Note deleted', description: note.title })
} catch (cause) {
if (!mounted.current) return
const message = cause instanceof Error && cause.message
? cause.message
: 'Could not delete this note. Try again.'
setError(message)
AccessibilityInfo.announceForAccessibility(message)
} finally {
pending.current = false
if (mounted.current) setBusy(false)
}
}
return (
<>
<FlatList
data={notes}
keyExtractor={(note) => note.id}
renderItem={({ item }) => (
<Item>
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
</ItemContent>
<ItemTrailing>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" accessibilityLabel={`Actions for ${item.title}`}>
More
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
destructive
onSelect={() => {
setError(undefined)
setSelected(item)
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ItemTrailing>
</Item>
)}
/>
<AlertDialog
open={selected !== null}
onOpenChange={(open) => {
if (!open && !pending.current) setSelected(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this note?</AlertDialogTitle>
<AlertDialogDescription>{selected?.title}</AlertDialogDescription>
</AlertDialogHeader>
{error ? (
<Alert variant="destructive" accessibilityLiveRegion="polite">
<AlertTitle>Delete failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AlertDialogFooter>
<AlertDialogAction
variant="destructive"
closeOnPress={false}
loading={busy}
onPress={remove}
>
Delete note
</AlertDialogAction>
<AlertDialogCancel disabled={busy}>Keep note</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

DropdownMenuItem closes after onSelect. AlertDialogAction also closes immediately by default, even when its handler returns a promise, so closeOnPress={false} matters here. A rejection keeps the confirmation open with a retryable Delete button. The toast runs only after success.

The row has no onPress and is not one grouped accessible element, so its More button stays reachable by screen readers. In an app with a query cache, update or invalidate that cache after deletion instead of keeping a second copy of the list.