Skip to content

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...

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:

  1. The first close request stores its result and hides the NiceModal instance.
  2. Repeated close requests reuse the same close promise and cannot change the stored result.
  3. Base UI reports its close transition through onOpenChangeComplete. For primitives without that callback, Surface observes the actual finite Web Animations attached to SurfaceDialogContent.
  4. The public promise resolves when no finite animation is still running.
  5. 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.

  • Replace closeOnOverlayClick with dismissible.
  • dialog.custom, dialog.confirm, and dialog.prompt now return null for dismissal instead of undefined.
  • Treat false as an explicit Confirm cancellation and null as dismissal.

These return values are part of the public contract and are shared with the Drawer variants.

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.

OptionDefaultBehavior
modaltrueTraps focus and uses the modal behavior supplied by shadcn Dialog.
showCloseButtontrueShows the Surface close icon in the top-right corner.
closeButtonLabel"Close"Accessible name for the icon-only close button. Override it for localization.
showTitleIcontrueDisplays titleIcon or the default alert-circle icon.
titleIconalert-circle iconAccepts any React node and is hidden from the accessibility tree.

dismissible defaults to true except for alerts, which require an explicit acknowledgement by default:

VariantDefaultEscape
customtrueCloses the active modal dialog and returns null.
alertfalseCloses the active modal dialog.
confirmtrueCloses the active modal dialog and returns null.
prompttrueCloses 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.

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 pathResult
close(value)The supplied value.
close()null.
Close iconnull.
Escape or allowed overlay pressnull.

Loading...

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...

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...

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 pathResult
Confirm actiontrue
Cancel actionfalse
Close iconnull
Escape or allowed overlay pressnull

Loading...

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 pathResult
Confirm action or EnterCurrent input string, including an empty string.
Cancel actionnull
Close iconnull
Escape or allowed overlay pressnull

Loading...

  • Alert and Confirm expose role="alertdialog"; Custom and Prompt use the shadcn Dialog role.
  • Titles use DialogTitle, and messages use DialogDescription, 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.

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...