| title | Dialog |
|---|---|
| description | A single <Dialog> for every overlay surface — centered modal, side-sheet, or bottom-sheet. |
| order | 3 |
A single <Dialog> component for every overlay surface — centered modal, side-sheet (left/right drawer), or bottom-sheet — controlled by the placement prop. Same component, same props on every platform.
import { Dialog, useDialogControl, Button } from '@oxyhq/bloom';
function SignOutButton() {
const control = useDialogControl();
return (
<>
<Button onPress={() => control.open()}>Sign out</Button>
<Dialog
control={control}
title="Sign out?"
description="You'll need to enter your password to sign in again."
actions={[
{ label: 'Sign out', color: 'destructive', onPress: doSignOut },
{ label: 'Cancel', color: 'cancel' },
]}
/>
</>
);
}Pass open + onClose to drive the dialog from external state instead of useDialogControl:
<Dialog
placement="center"
open={isOpen}
onClose={() => setIsOpen(false)}
title="Confirm?"
actions={[
{ label: 'OK', onPress: handleOk },
{ label: 'Cancel', color: 'cancel' },
]}
/>// Fixed left side-sheet
<Dialog placement="left" control={control} title="Filters">
<FilterPanel />
</Dialog>
// Responsive: bottom-sheet on mobile, left drawer on desktop
<Dialog placement={{ base: 'bottom', md: 'left' }} control={control} title="Filters">
<FilterPanel />
</Dialog>Provide any JSX as children. Combine with title to keep a consistent header. Set contentPadding={0} when the children own their own insets.
<Dialog control={control} title="Pick a tag" contentPadding={0}>
<YourCustomBody />
</Dialog>Drop the declarative props entirely — children owns every pixel.
<Dialog control={control}>
<YourEntirelyCustomLayout />
</Dialog>| Prop | Type | Default | Description |
|---|---|---|---|
control? |
DialogControl |
— | From useDialogControl(). Omit when using controlled open. |
open? |
boolean |
— | Controlled open state. When provided, wins over control. |
placement? |
DialogPlacement | ResponsiveDialogPlacement |
'center' |
Surface anchor. See Placement. |
title? |
string |
— | Header text. |
description? |
string |
— | Supporting copy below the title. |
actions? |
DialogAction[] |
— | Confirmation buttons. See DialogAction. |
children? |
React.ReactNode |
— | Custom content rendered after the description. |
onClose? |
() => void |
— | Fires after the dialog finishes closing. In controlled mode, the host flips open to false. |
contentPadding? |
number |
20 |
Inner padding of the dialog body (px). Set 0 for custom children that own their insets. |
width? |
number |
460 |
Side-sheet width (px) on left/right placements. |
maxWidth? |
number |
480 |
Centered-card max width (px). |
maxHeightRatio? |
number |
0.9 |
Bottom-sheet max height as a fraction of the viewport height. |
inset? |
{ top?; bottom?; left?; right? } |
— | Side-sheet inset (px) from the overlay container edges. |
showHandle? |
boolean |
true |
Show drag handle in bottom-sheet mode. |
dismissOnBackdrop? |
boolean |
true |
Tap backdrop to dismiss. |
panelStyle? |
StyleProp<ViewStyle> |
— | Style for the panel surface. |
panelClassName? |
string |
— | NativeWind class for the panel surface. |
containerStyle? |
StyleProp<ViewStyle> |
— | Style for the root overlay (e.g. rail offset, theme-var scope). |
containerClassName? |
string |
— | NativeWind class for the root overlay. |
label? |
string |
— | Accessibility label. |
testID? |
string |
— | RN testing ID. |
placement accepts a single value or a responsive map resolved by useWindowDimensions():
type DialogPlacement = 'center' | 'left' | 'right' | 'bottom';
type ResponsiveDialogPlacement =
| DialogPlacement
| { base: DialogPlacement; sm?: DialogPlacement; md?: DialogPlacement; lg?: DialogPlacement; xl?: DialogPlacement };Breakpoints (px): sm 640 / md 768 / lg 1024 / xl 1280. The largest breakpoint whose min-width is <= the viewport wins; falls back to base below all breakpoints.
Per-placement behavior:
center— DOM-portal modal (web) / detached floating card (native).left/right— animated side-sheet anchored to the viewport edge.bottom— reuses the cross-platformBottomSheeton BOTH web and native (drag-to-dismiss + internal scroll work everywhere). Customchildrenon bottom placement are rendered in a NON-scrollable body — children own scrolling.
| Prop | Type | Description |
|---|---|---|
label |
string |
Button text. |
color? |
'default' | 'cancel' | 'destructive' |
Defaults to 'default'. |
onPress? |
(e) => void |
Invoked after the dialog finishes closing. |
disabled? |
boolean |
|
shouldCloseOnPress? |
boolean |
Defaults to true. Set false while an async action is in flight. |
testID? |
string |
A backdrop blurs its content on iOS and web with no setup. On Android it needs
the app content wrapped in a blur target, which BloomProvider composes — so
if you mount it, there is nothing to do:
import { BloomProvider } from '@oxyhq/bloom/provider';
<BloomProvider>{children}</BloomProvider>An app that does not mount BloomProvider keeps the tint-only backdrop. That is
a supported state, not a missing step — see below.
Two things are worth knowing rather than discovering:
- It applies to the bottom-sheet path only.
expo-bluron Android blurs a target view, and a blur view that is a DESCENDANT of its own target crashes the app —SIGSEGV, an unbounded render-tree recursion. Only a surface in a separate native window is safe, which means a real RN<Modal>: that isBottomSheet, andDialog's bottom placement through it.Dialog's centre and side placements portal into the same window and keep the tint-only backdrop. Bloom enforces this structurally — the target is simply not reachable from a surface that has not crossed a window boundary — so there is no wiring you can get wrong. - Not mounting it is a supported state, not a broken one. The backdrop still paints its tint at full intensity; you lose the blur and nothing else, with no warning and no crash.
Inject the CSS animations into your global styles once:
import { BLOOM_DIALOG_CSS } from '@oxyhq/bloom/dialog';
// In your HTML head or global CSS file:
<style>{BLOOM_DIALOG_CSS}</style><Dialog
control={control}
title="Delete project?"
description="This will permanently delete the project and all of its files."
actions={[
{ label: 'Delete', color: 'destructive', onPress: handleDelete },
{ label: 'Archive', onPress: handleArchive },
{ label: 'Cancel', color: 'cancel' },
]}
/>const [busy, setBusy] = useState(false);
<Dialog
control={control}
title="Submit?"
actions={[
{
label: busy ? 'Submitting…' : 'Submit',
disabled: busy,
shouldCloseOnPress: false, // keep dialog open while we work
onPress: async () => {
setBusy(true);
try {
await submit();
control.close();
} finally {
setBusy(false);
}
},
},
{ label: 'Cancel', color: 'cancel' },
]}
/><Dialog control={control} title="Add tag">
<TextFieldInput label="Tag name" value={name} onChangeText={setName} />
<Button onPress={() => { save(name); control.close(); }}>Save</Button>
</Dialog>Bottom-sheet on small screens, left drawer on md and wider:
<Dialog
placement={{ base: 'bottom', md: 'left' }}
control={control}
title="Store settings"
containerClassName="md:left-[4.75rem]"
>
<SettingsPanel />
</Dialog>CenteredDialog and ResponsiveSheet were removed in 0.16.x.
// Before
<CenteredDialog visible={v} onClose={c}>…</CenteredDialog>
// After
<Dialog placement="center" open={v} onClose={c}>…</Dialog>
// Before
<ResponsiveSheet side="left" open={o} onClose={c}>…</ResponsiveSheet>
// After
<Dialog placement={{ base: 'bottom', md: 'left' }} open={o} onClose={c}>…</Dialog>Removed exports: CenteredDialog, CenteredDialogProps, BLOOM_CENTERED_DIALOG_CSS, CENTERED_DIALOG_BACKDROP_TESTID, ResponsiveSheet, @oxyhq/bloom/responsive-sheet subpath.