Skip to main content

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​

PropTypeDefaultDescription
titlestring—Title text displayed in the modal header
hideHeaderbooleanfalseWhen 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​

PropertyTypeDescription
onRequestClose() => voidCallback 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)
isDialogbooleanShorthand for type === 'dialog'
titlestringTitle 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>