bx

Dialog

A window overlaid on either the primary window or another dialog window.

tsx
<Dialog>
  <DialogTrigger for="edit-profile">
    <Button variant="outline">Edit Profile</Button>
  </DialogTrigger>
  <DialogContent id="edit-profile">
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>
        Make changes to your profile here. Click save when you're done.
      </DialogDescription>
    </DialogHeader>
    <div className="grid gap-4 py-2">
      <div className="grid gap-2">
        <Label htmlFor="name">Name</Label>
        <Input id="name" value="Sofia Davis" />
      </div>
      <div className="grid gap-2">
        <Label htmlFor="user">Username</Label>
        <Input id="user" value="@sofia" />
      </div>
    </div>
    <DialogFooter>
      <DialogClose>
        <Button>Save changes</Button>
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

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

Terminal
$ bext ui add dialog

Usage

tsx
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from "@bext-stack/ui/primitives"
import { Button } from "@bext-stack/ui/primitives"

<Dialog>
  <DialogTrigger for="edit-profile">
    <Button variant="outline">Edit Profile</Button>
  </DialogTrigger>
  <DialogContent id="edit-profile">
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
      <DialogDescription>
        Make changes to your profile here. Click save when you're done.
      </DialogDescription>
    </DialogHeader>
    {/* form fields */}
    <DialogFooter>
      <DialogClose>
        <Button>Save changes</Button>
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

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

tsx
<Dialog>
  <DialogTrigger for="confirm-delete">
    <Button variant="outline">Delete item</Button>
  </DialogTrigger>
  <DialogContent id="confirm-delete">
    <DialogHeader>
      <DialogTitle>Delete this item?</DialogTitle>
      <DialogDescription>
        This action cannot be undone. The item will be permanently removed.
      </DialogDescription>
    </DialogHeader>
    <DialogFooter>
      <DialogClose><Button variant="outline">Cancel</Button></DialogClose>
      <DialogClose><Button variant="destructive">Delete</Button></DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

Header without description

tsx
<DialogContent id="invite">
  <DialogHeader>
    <DialogTitle>Invite team members</DialogTitle>
  </DialogHeader>
  <div className="py-2 text-sm text-muted-foreground">Enter an email to send an invitation link.</div>
  <Input placeholder="colleague@example.com" aria-label="Email address" />
  <DialogFooter>
    <DialogClose><Button>Send invite</Button></DialogClose>
  </DialogFooter>
</DialogContent>
Try it live
Open in play

Edit this component live in the bext playground. The PRISM + signals version is the bext-native idiom: fine-grained reactivity, no virtual DOM.

src/app/page.tsx 2 files · signals · runs in your browser
Pure PRISM + real bext signals: fine-grained, no re-render.