Dialog
A window overlaid on either the primary window or another dialog window.
Edit profile
Make changes to your profile here. Click save when you're done.
Dialog is a centered modal that interrupts the user flow for a specific action (editing a profile, confirming an operation, a compact form). Unlike a Sheet or Drawer, which slide in from an edge, the Dialog appears in the center of the screen with a blocking backdrop.
PRISM architecture: the trigger wraps your button in a <span> with data-ui="open" data-target="#dlg-id". The content is a <div data-overlay hidden> with the matching id. The ui.js island intercepts the click on the trigger, removes hidden, applies overflow:hidden to the body and moves focus into the dialog. Escape, a backdrop click or data-ui="close" closes it and returns focus to the trigger.
The trigger↔content link goes through a shared id, not a React context. It is more verbose, but it allows several Dialogs on the same page without state conflicts.
Idiomatic composition:
- DialogHeader: title + description above the body.
- DialogTitle: semantic
<h2>, heading role for assistive technologies. - DialogDescription: muted paragraph, read by screen readers after the title.
- DialogFooter: action bar at the bottom (typically Cancel + Save, right-aligned).
- DialogClose: wrapper that closes the dialog when its child is clicked (uses
data-ui="close").
For a destructive confirmation (Delete account, Discard changes), use AlertDialog instead: the same overlay without the corner close button, with ready-made Cancel and Action buttons.
Installation
$ bext ui add dialog
Copy and paste the following into src/components/ui/dialog.tsx.
This component is interactive. Make sure the shared island is loaded (see Islands). It reacts to data-ui="dialog" hooks.
Usage
Anatomy
<Dialog>
├── <DialogTrigger for="dlg-id"> // data-ui="open"
│ └── <Button>Edit Profile</Button>
└── <DialogContent id="dlg-id"> // data-overlay hidden
├── <DialogHeader>
│ ├── <DialogTitle>Edit profile</DialogTitle>
│ └── <DialogDescription>...</DialogDescription>
├── <div>...form fields...</div> // free-form body
└── <DialogFooter>
└── <DialogClose>
└── <Button>Save</Button>API Reference
Dialog
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The trigger and the content. Renders no element of its own. |
DialogTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| for | string | — | Must match the id of the DialogContent to open. Generates data-target="#for". |
| children | ReactNode | — | Button or link that opens the dialog. |
DialogContent
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Must match DialogTrigger.for. Used as the data-target of the trigger. |
| showClose | boolean | true | Renders the × button in the top-right corner (aria-label="Close"). Pass false to remove it. |
| ariaLabel | string | — | Accessible name (aria-label) for a dialog that has no DialogTitle. With a DialogTitle, leave it out: the island links the title with aria-labelledby. |
| className | string | — | CSS classes. Default: centered fixed, w-full max-w-lg, bg-background rounded-lg border shadow-lg. |
DialogHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | flex flex-col space-y-1.5 text-center sm:text-left, above the body. |
DialogTitle
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Optional id. Without it the island generates one (content id + "-title") to build aria-labelledby. |
| className | string | — | <h2> styled with text-lg font-semibold leading-none tracking-tight. |
DialogDescription
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Optional id. Without it the island generates one (content id + "-description") to build aria-describedby. |
| className | string | — | <p> styled with text-sm text-muted-foreground. |
DialogFooter
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Bottom action bar. flex-col-reverse on mobile, flex-row justify-end on sm+. |
DialogClose
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Wraps a button/link with data-ui="close" to close the dialog. |
Data Attributes
Attributes you can target in custom CSS to override the style for each state.
| Attribute | On | Values | Description |
|---|---|---|---|
| data-ui="open" | DialogTrigger | data-target="#dlg-id" | Clicking opens the targeted overlay and locks page scroll. |
| data-state | DialogTrigger | "open" | "closed" | Set by the island. The button inside the trigger gets aria-haspopup="dialog", aria-expanded and aria-controls. |
| data-slot | DialogTitle, DialogDescription | "dialog-title" | "dialog-description" | Hooks the island uses to name and describe the dialog. |
| data-overlay | DialogContent (outer element) | hidden by default | Marker for the island: the hidden attribute is removed on open and restored on close. |
| data-ui="close" | DialogClose, corner × button | — | Clicking closes the nearest [data-overlay] ancestor. |
| data-ui="overlay-bg" | backdrop | — | Clicking the backdrop closes the overlay. |
Keyboard
| Key | Action |
|---|---|
| Enter / Space | On the trigger: opens the dialog and moves focus to its first focusable element. Inside: activates the focused button. |
| Tab / Shift+Tab | Moves through the focusable elements of the dialog and wraps around. Focus cannot leave the dialog while it is open. |
| Escape | Closes the dialog and returns focus to the trigger. |
Accessibility
DialogContent sets role="dialog" aria-modal="true". The island points aria-labelledby at the DialogTitle and aria-describedby at the DialogDescription, generating their ids from the content id when you give none. A dialog without a DialogTitle is named by its trigger button, or by the ariaLabel prop. The corner close button has aria-label="Close".
Trigger: the island sets aria-haspopup="dialog", aria-controls and aria-expanded on the button inside DialogTrigger, and keeps aria-expanded in sync.
Focus: on open, focus moves to the first focusable element of the content (the corner close button is used only when there is nothing else, and the panel itself when there is nothing focusable). Tab and Shift+Tab are trapped inside. On close, focus returns to the element that opened the dialog. Page scroll is locked while a dialog is open.
Backdrop: clicking outside closes the dialog, and so does Escape. There is no option to turn either off, so do not rely on a dialog to hold unsaved input; use AlertDialog when the user must answer.
Nested dialogs: possible but discouraged. Prefer a linear flow (Dialog → close → next Dialog).
Examples
Simple confirmation
Delete this item?
This action cannot be undone. The item will be permanently removed.
Header without description
Invite team members
Edit this component live in the bext playground. The PRISM + signals version is the bext-native idiom: fine-grained reactivity, no virtual DOM.
On This Page