Dialog
Dialog opens promise-based interactions from event handlers and other imperative workflows. It composes the shadcn Dialog, Button, and Input components already installed in the consumer project.
The same registry item supports shadcn projects configured with Base UI or Radix. Surface only uses the common shadcn component API and does not import a primitive package directly. Complete the shared Surface installation and Provider setup before opening a Dialog.
Loading...
Lifecycle
Section titled “Lifecycle”Every call creates an independent NiceModal instance. Calling an Opalus action,
the custom close callback, the close icon, Escape, or an allowed overlay press
starts the same close sequence:
- The first close request stores its result and hides the NiceModal instance.
- Repeated close requests reuse the same close promise and cannot change the stored result.
- Base UI reports its close transition through
onOpenChangeComplete. For primitives without that callback, Surface observes the actual finite Web Animations attached toSurfaceDialogContent. - The public promise resolves when no finite animation is still running.
- Surface resolves the NiceModal hide promise and removes the instance.
Surface does not use a fixed JavaScript close timeout. A dialog without a running finite animation is removed on the next animation frame. Paused, cancelled, idle, zero-rate, and infinite animations never block cleanup.
For animation-aware custom dialog cleanup, use SurfaceDialogContent as the
rendered shadcn Dialog content. If custom content renders a raw DialogContent,
Surface still cleans up the NiceModal instance, but it cannot wait for an
unregistered content animation.
Migration From The Initial API
Section titled “Migration From The Initial API”- Replace
closeOnOverlayClickwithdismissible. dialog.custom,dialog.confirm, anddialog.promptnow returnnullfor dismissal instead ofundefined.- Treat
falseas an explicit Confirm cancellation andnullas dismissal.
These return values are part of the public contract and are shared with the Drawer variants.
Shared Types
Section titled “Shared Types”type SurfaceButtonProps = Omit< React.ComponentProps<typeof Button>, "children" | "onClick" | "type">
type CustomDialogOptions = { modal?: boolean dismissible?: boolean}
type DialogOptions = CustomDialogOptions & { showCloseButton?: boolean closeButtonLabel?: string title?: React.ReactNode titleIcon?: React.ReactNode showTitleIcon?: boolean}SurfaceButtonProps passes normal shadcn Button options such as variant,
size, disabled, and className to an action without allowing the consumer to
replace the action’s internal click handler or button type.
Shared defaults
Section titled “Shared defaults”| Option | Default | Behavior |
|---|---|---|
modal | true | Traps focus and uses the modal behavior supplied by shadcn Dialog. |
showCloseButton | true | Shows the Surface close icon in the top-right corner. |
closeButtonLabel | "Close" | Accessible name for the icon-only close button. Override it for localization. |
showTitleIcon | true | Displays titleIcon or the default alert-circle icon. |
titleIcon | alert-circle icon | Accepts any React node and is hidden from the accessibility tree. |
dismissible defaults to true except for alerts, which require an explicit
acknowledgement by default:
| Variant | Default | Escape |
|---|---|---|
custom | true | Closes the active modal dialog and returns null. |
alert | false | Closes the active modal dialog. |
confirm | true | Closes the active modal dialog and returns null. |
prompt | true | Closes the active modal dialog and returns null. |
When modal is false, automatic overlay and Escape closing are disabled. The
dialog closes only through the provided close callback or an Opalus action.
When overlay closing is disabled for a modal dialog, Surface listens for Escape
on that dialog’s own ownerDocument. It only handles the event when the event
target is inside the current SurfaceDialogContent, so nested dialogs and
separate React roots do not share a global Surface stack.
Custom Dialog
Section titled “Custom Dialog”dialog.custom<T>( content: (close: (result?: T) => Promise<void>) => React.ReactNode, options?: CustomDialogOptions): Promise<T | null>Use SurfaceDialogContent as the custom content adapter. It is the only custom
layout component exported by Surface. Compose the rest of the dialog with the
consumer’s existing shadcn components:
function EditProjectDialog({ close,}: { close: (result?: string) => Promise<void>}) { const [name, setName] = React.useState("")
return ( <SurfaceDialogContent showCloseButton={false}> <DialogHeader> <DialogTitle>Edit project</DialogTitle> <DialogDescription> Enter the name shown across this workspace. </DialogDescription> </DialogHeader>
<Input aria-label="Project name" value={name} onChange={(event) => setName(event.target.value)} />
<Button onClick={() => void close(name)}>Save</Button> </SurfaceDialogContent> )}
const name = await dialog.custom<string>((close) => ( <EditProjectDialog close={close} />))The callback passed to dialog.custom is a render callback, not a React
component. Do not call Hooks directly inside it. Render a normal React component
from the callback when custom content needs state, effects, refs, or other
Hooks.
close(result) is idempotent and returns a promise that completes after the
exit animation. The dialog.custom result is:
| Close path | Result |
|---|---|
close(value) | The supplied value. |
close() | null. |
| Close icon | null. |
| Escape or allowed overlay press | null. |
Loading...
Non-modal Dialog
Section titled “Non-modal Dialog”Set modal to false when the Dialog should not trap focus or make the rest of
the page inert. While the dialog is open, use the background counter in this
example to verify that controls behind it remain interactive.
Surface also hides the shadcn Dialog overlay for non-modal content, so the page is not dimmed or blurred. Non-modal dialogs do not close automatically through Escape or an overlay press. Keep an explicit close action inside the content.
Loading...
Alert Dialog
Section titled “Alert Dialog”type AlertDialogOptions = DialogOptions & { message?: React.ReactNode closeButtonContent?: React.ReactNode closeButtonProps?: SurfaceButtonProps}
dialog.alert(options?: AlertDialogOptions): Promise<void>Alert uses the current shadcn Dialog visual structure with role="alertdialog"
and connects message through DialogDescription. Overlay closing defaults to
false, so the user must acknowledge the alert, use the close icon, or press
Escape unless the option is explicitly changed.
The returned promise has no value and resolves after the alert exit animation finishes.
await dialog.alert({ title: "Changes saved", message: "The project settings are now up to date.", closeButtonContent: "Done", closeButtonProps: { variant: "secondary" },})Loading...
Confirm Dialog
Section titled “Confirm Dialog”type ConfirmDialogOptions = DialogOptions & { message?: React.ReactNode confirmButtonContent?: React.ReactNode confirmButtonProps?: SurfaceButtonProps cancelButtonContent?: React.ReactNode cancelButtonProps?: SurfaceButtonProps}
dialog.confirm(options?: ConfirmDialogOptions): Promise<boolean | null>Confirm uses role="alertdialog", exposes separate shadcn Button props for both
actions, and allows overlay dismissal by default. Use
confirmButtonProps.variant for destructive actions without replacing the
button implementation:
const confirmed = await dialog.confirm({ title: "Delete project?", message: "This action cannot be undone.", cancelButtonContent: "Keep project", confirmButtonContent: "Delete project", confirmButtonProps: { variant: "destructive" },})| Close path | Result |
|---|---|
| Confirm action | true |
| Cancel action | false |
| Close icon | null |
| Escape or allowed overlay press | null |
Loading...
Prompt Dialog
Section titled “Prompt Dialog”type PromptDialogOptions = DialogOptions & { message?: React.ReactNode inputLabel?: string defaultValue?: string placeholder?: string inputProps?: Omit< React.ComponentProps<typeof Input>, "defaultValue" | "name" | "value" > confirmButtonContent?: React.ReactNode confirmButtonProps?: SurfaceButtonProps cancelButtonContent?: React.ReactNode cancelButtonProps?: SurfaceButtonProps}
dialog.prompt(options?: PromptDialogOptions): Promise<string | null>Prompt renders the existing shadcn Input as an uncontrolled form field. It does not call Hooks inside the custom render callback. Pressing Enter submits the current value; IME composition Enter events are ignored until composition finishes.
inputLabel supplies the Input accessible name and defaults to "Input".
inputProps passes normal Input props such as required, maxLength,
autoComplete, disabled, className, and input event handlers. Surface owns
the field name, value mode, and default value so it can return the submitted
string reliably.
const projectName = await dialog.prompt({ title: "Rename project", message: "Choose a short name for this project.", inputLabel: "Project name", defaultValue: "Opalus UI", placeholder: "Enter a project name", inputProps: { autoComplete: "off", maxLength: 64, }, confirmButtonContent: "Rename",})| Close path | Result |
|---|---|
| Confirm action or Enter | Current input string, including an empty string. |
| Cancel action | null |
| Close icon | null |
| Escape or allowed overlay press | null |
Loading...
Accessibility and Keyboard Behavior
Section titled “Accessibility and Keyboard Behavior”- Alert and Confirm expose
role="alertdialog"; Custom and Prompt use the shadcn Dialog role. - Titles use
DialogTitle, and messages useDialogDescription, providing the accessible name and description relationships supplied by shadcn. - The close icon is an icon-only shadcn Button with a configurable
aria-label. - Prompt always provides an accessible Input name through
inputLabel. - Prompt submits with Enter while preserving IME composition behavior.
- Escape affects only the focused Surface content when Surface must handle the key itself.
- Focus trapping, focus restoration, pointer dismissal, and overlay behavior remain owned by the installed shadcn Dialog primitive.
Cascade Dialogs
Section titled “Cascade Dialogs”Each dialog.custom call creates a unique NiceModal component type and ID. This
allows a Dialog to open another Dialog without replacing the parent instance.
The parent promise remains pending until its own close sequence finishes.
Stateful cascade content must follow the same Hooks rule as any custom dialog:
put Hooks in a rendered React component, not directly in the dialog.custom
callback.
Loading...