Popup
Sheet/dialog modal component with built-in header and backdrop. Presentation is automatic and platform-based: it renders as a bottom sheet on native and as a centered dialog on web — there is no prop to override this.
Basic Usage
import { Popup } from '@multinaire/expo-ui';
<Popup
title="Modal Title"
renderToggleButton={({ onPress }) => (
<Button title="Open" onPress={onPress} />
)}
>
<Typography>Modal content</Typography>
</Popup>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Title text displayed in the modal header |
hideHeader | boolean | false | When true, the modal header (title bar) is hidden |
renderToggleButton | (props) => ReactNode | — | Render prop that returns the button used to toggle the modal open/closed |
Width
Both presentations are capped at MAX_WIDTHS.modal (460px) and centered once the window is wider than the mobile breakpoint (640px), so a popup is the same width on a tablet whichever platform it runs on. Below that breakpoint the native sheet spans the full width, as a bottom sheet should on a phone.
On native it is the sheet itself that is inset to the cap, not its content, so everything inside a capped sheet lays out exactly as it does at full width — content that fills the sheet with flex: 1 still fills it. The window behind stays dimmed edge to edge, and tapping the dim either side of the sheet closes the popup, just as tapping above it does.
Backdrop Behavior
Tapping the backdrop closes the popup on iOS. On Android the backdrop tap is disabled by default to prevent accidental dismissal — use the header close button or usePopup().onRequestClose() instead.
Lazy Mounting
Popup content is mounted only while the popup is open. Opening mounts the content already at its open position, so it animates in as usual, and closing unmounts it once the close animation has finished — the popup animates out in full before its content goes away.
This means a closed Popup costs nothing beyond its toggle button: children run no effects and hold no subscriptions until the popup is opened. It also means content state does not survive a close, so every open starts from a clean slate.
<Popup
title="Search"
renderToggleButton={({ onPress }) => (
<Button title="Open" onPress={onPress} />
)}
>
{/* Mounted on open, unmounted after close — the search text
is empty again the next time the popup is opened. */}
<ListPicker
type="list"
items={items}
searchPredicate={(item, filter) => item.name.includes(filter)}
onChange={setSelected}
/>
</Popup>
Keep any state that must outlive a close in the component that renders the Popup, and pass it down.
usePopup Hook
Access modal context from within modal children for programmatic control.
import { usePopup } from '@multinaire/expo-ui';
function ModalContent() {
const { onRequestClose } = usePopup();
return (
<Button
title="Close Modal"
onPress={onRequestClose}
/>
);
}
Hook Return Values
| Property | Type | Description |
|---|---|---|
onRequestClose | () => void | Callback invoked when the modal requests to be closed (e.g. back button, backdrop tap) |
type | 'sheet' | 'dialog' | Resolved presentation style of the modal — always 'sheet' on native and 'dialog' on web. Useful for adjusting content (e.g. bottom safe-area insets only apply to sheets) |
isDialog | boolean | Shorthand for type === 'dialog' |
title | string | Title text displayed in the modal header |
Examples
Basic Modal
<Popup
title="Select Option"
renderToggleButton={({ onPress }) => (
<Button title="Open" onPress={onPress} />
)}
>
<Container padding={variables.padding.medium}>
<Typography>Slides up from the bottom on native, centers on web</Typography>
</Container>
</Popup>
Without Header
<Popup
hideHeader
renderToggleButton={({ onPress }) => (
<Button title="Open" onPress={onPress} />
)}
>
<CustomHeader />
<Typography>Custom header content</Typography>
</Popup>