Dialog
dialogPresents modal content in a portal with focus trapping.
Usage
Basic usage
Trigger, Portal + focus trap: Esc close, focus return trigger button.
<Dialog>
<DialogTrigger render={<Button variant="outline">Open dialog box</Button>} />
<DialogContent title="Hulian Dialog Box" description="Auxiliary explanation copy under the title.">
<div className="flex justify-end gap-2">
<DialogClose render={<Button variant="ghost">Cancel</Button>} />
<DialogClose render={<Button>OK</Button>} />
</div>
</DialogContent>
</Dialog>footer operating area
Use footer slot to place the bottom operation button, automatically with top divider and right alignment.
<Dialog>
<DialogTrigger render={<Button>Delete item</Button>} />
<DialogContent
title="Confirm deletion"
description="This operation is irreversible. Are you sure you want to delete this item?"
footer={
<>
<DialogClose render={<Button variant="ghost">Cancel</Button>} />
<DialogClose render={<Button tone="danger">Delete</Button>} />
</>
}
/>
</Dialog>Open by default
Uncontrolled use defaultOpen to make the dialog box expand initially.
<Dialog defaultOpen>
<DialogTrigger render={<Button variant="outline">Open dialog box</Button>} />
<DialogContent title="Welcome" description="The dialog box is initially open.">
<div className="flex justify-end">
<DialogClose render={<Button>Got it</Button>} />
</div>
</DialogContent>
</Dialog>Draggable
draggable turns the title row into a handle: hold the title to move the dialog aside and see the page underneath; buttons in extra on the right of the title still click.
<Dialog>
<DialogTrigger render={<Button variant="outline">Open draggable dialog</Button>} />
<DialogContent
draggable
title="Select attachment"
description="Hold the title to drag."
extra={<Button size="sm" variant="ghost">Refresh</Button>}
footer={<DialogClose render={<Button>Done</Button>} />}
>
<p className="text-sm text-muted-foreground">Once the dialog is moved aside, the page content underneath stays visible.</p>
</DialogContent>
</Dialog>When to use
Use Dialog to interrupt the current flow with a form, details, or confirmation above an overlay. It includes a Portal, focus containment, and Escape dismissal. Use Modal for one-line imperative confirm or status calls, AlertDialog when the user must explicitly decide, or Drawer for a sliding panel.
Import
import { Dialog, DialogTrigger, DialogClose, DialogContent } from "@hulianui/ui"Props
Dialog, DialogTrigger, and DialogClose are thin wrappers around the matching Base UI primitives. Dialog forwards Root props such as open, defaultOpen, and onOpenChange; Trigger and Close support render to supply the rendered element. DialogContent adds HulianUI styling:
| Name | Type | Default | Description |
|---|---|---|---|
DialogContent.title | ReactNode | - | Visible title and the usual source of the accessible name. Accepts a node, so an icon plus text works. The host element is an <h2> and accepts phrasing content only; put button rows in extra. Optional since 0.47.0 (see "A dialog must have a name"). |
DialogContent.extra | ReactNode | - | Actions to the right of the title, right-aligned on the title row. Never contributes to the accessible name. |
DialogContent.description | ReactNode | - | Supporting copy. Rendered inside a <p>, so phrasing content only. Put block-level content in children. |
DialogContent.aria-label | string | - | Accessible name for the dialog, applied directly to the popup. Use it instead of title when the visible header is drawn by the consumer. |
DialogContent.aria-labelledby | string | - | Id of the element that names the dialog; wins over the id generated from title. Supply either this or aria-label. |
DialogContent.showClose | boolean | true | Top-right close button (#279; shape and default match DrawerContent). Read-only detail dialogs (no footer) previously had no visible exit besides the backdrop, keyboard users only Esc, and screen readers found no reachable "Close" element. When on, the title/extra row reserves the top-right 40px. Pass false for layers with their own close affordance, such as a global search box. |
DialogContent.closeLabel | string | locale dialog.close | Accessible name of the close button; falls back to the ConfigProvider locale (dialog.close, "Close" in enUS). |
DialogContent.titleClassName | string | - | Appended to the title (defaults to text-lg font-semibold), merged with twMerge. |
DialogContent.descriptionClassName | string | - | Appended to the description (merged with twMerge). Pass sr-only for a screen-reader-only description, which keeps the visible header to the title alone while assistive technology still reads the sentence. |
DialogContent.backdrop | boolean | true | Whether to render the backdrop. Setting it to false together with modal={false} on the root is what makes an overlay truly non-modal; turning off only one is not enough, because the inset-0 backdrop swallows every click even when it is transparent. |
DialogContent.backdropClassName | string | - | Appended to the backdrop, whose default is bg-black/40 backdrop-blur-sm. Classes merge with twMerge, so dimming and blur can follow your design system. The "steps aside once dragged" behavior of draggable also lives here, so overriding the same variants (data-[dragged]:bg-black/40 data-[dragged]:backdrop-blur-sm) keeps the backdrop as it was. |
DialogContent.scrollable | boolean | true | Whether the body scrolls itself. When false, the body becomes a column flex container that passes a definite height to its children, so a two-pane layout only needs flex-1 min-h-0 instead of a hand-tuned h-[58vh]. |
DialogContent.bodyClassName | string | - | Appended to the body container. |
DialogContent.draggable | boolean | false | Lets the user move the dialog by holding the title row. The handle is the title row (where title and extra live); buttons in that row still click instead of dragging. Mark your own element with data-drag-handle when you draw the header yourself. The popup stays inside the viewport and returns to its initial position on every open, and the backdrop steps aside once the dialog has been moved (dimming drops to 10%, blur goes away) — otherwise nothing underneath becomes readable. See "Draggable" below. |
DialogContent.className | string | - | Content-container class name. |
Events
| Event | Type | Description |
|---|---|---|
Dialog.onOpenChange | (open: boolean) => void | Called when the open state changes; forwarded to Base UI Dialog Root. |
Slots
| Slot | Type | Description |
|---|---|---|
DialogContent.footer | ReactNode | Action area below the body with a top divider and right alignment, matching DrawerContent. |
DialogContent.children | ReactNode | Main body content. |
Example
<Dialog>
<DialogTrigger render={<Button variant="outline">Open dialog</Button>} />
<DialogContent title="Hulian dialog" description="Focus stays inside, Escape closes, and focus returns to the trigger.">
<div className="flex justify-end gap-2">
<DialogClose render={<Button variant="ghost">Cancel</Button>} />
<DialogClose render={<Button>Confirm</Button>} />
</div>
</DialogContent>
</Dialog>Draggable
draggable is a mouse and touch convenience: hold the title row to move the dialog aside, look at what it was covering, and come back. A few boundaries:
- The handle is the title row. When
titlestands alone, the<h2>is the handle; withextrathe whole row is, and interactive elements inside it (buttons and the like) take the press themselves instead of starting a drag. When you draw the visible header yourself (notitle/extra), mark your own handle withdata-drag-handle:
<DialogContent aria-label="Notifications" draggable className="p-0 [--hl-overlay-pad:0px]">
<div data-drag-handle className="cursor-move border-b px-4 py-3">…</div>
{/* body */}
</DialogContent>- The offset is written to the popup's inline `left` / `top`, never to `translate` / `transform` (those two sit in the overlay transition list, so carrying the drag on them would ease every step). An initial position you set through
className(such astop-10 translate-y-0) keeps working; dragging only adds on top of its result. - The popup never leaves the viewport, and every open starts from the initial position (Base UI unmounts the popup on close, and the offset goes with it).
- The backdrop steps aside once the dialog has been moved. The default backdrop is 40% black plus blur, which says "do not look behind me" and cancels out exactly what
draggableis for; without this, dragging only parks an unreadable page somewhere else. So the moment a drag actually displaces the popup (not on press, and not merely because the option is on), the backdrop gets adata-draggedmarker, drops to 10% dimming with no blur, and stays that way after the pointer is released. A draggable dialog nobody has dragged looks exactly as before.
Keeping 10% instead of going fully transparent is deliberate: the backdrop still swallows every click, which is what modal means, and a fully invisible layer would turn "visible but unclickable" into an unexplained oddity. To keep the original backdrop, override the same variants through backdropClassName:
<DialogContent draggable backdropClassName="data-[dragged]:bg-black/40 data-[dragged]:backdrop-blur-sm" title="…">Usage guidelines
- A non-modal overlay takes two changes:
modal={false}on the root, which releases the focus and scroll locks, plusbackdrop={false}on the content, which stops rendering the backdrop. Changing only the first leaves afixed inset-0layer that swallows every click even while transparent, so nothing actually becomes non-modal. - With
scrollable={false}, vertical scrolling becomes your responsibility: the body only passes a definite height down as a column flex container, and each child needs its ownoverflow-y-auto. Forgetting that clips the content atmax-h.
- Use
render={<Button … />}on DialogTrigger and DialogClose to merge behavior into the target element. Do not wrap another button around them; that creates nested interactive elements and duplicate click handling. - Prefer the
footerslot for actions so it receives the divider and alignment, leavingchildrenfor primary content. draggablehas no keyboard equivalent, so do not make "move it aside to read what is underneath" a precondition of the task; a form that must be filled in while reading the page belongs in a non-modal overlay (modal={false}on the root plusbackdrop={false}).- A dialog that draws its own header must mark its handle with
data-drag-handlewhen it turns ondraggable: the handle defaults to the title row alone, so with notitle/extraand no marked handle the option looks on but nothing moves.
A dialog must have a name
title was required before 0.47.0, but that never actually guaranteed a name: title={null} type-checks and renders an empty <h2>. It is now optional, and the guarantee moved to a runtime warning: supplying none of title / aria-label / aria-labelledby logs a development warning.
So an edge-to-edge dialog whose visible header is a row of controls no longer needs an sr-only placeholder title:
<DialogContent aria-label="Notifications" className="p-0 [--hl-overlay-pad:0px]">
<div className="flex items-center justify-between border-b px-4 py-3">…</div>
{/* body */}
</DialogContent>When the title just needs a couple of buttons beside it, use extra rather than pushing the whole row into title: an <h2> accepts phrasing content only, and aria-labelledby points at the whole <h2>, so button copy would be read out as part of the dialog's name. The same slot exists on DrawerContent.extra and CardHeader.extra.
Related
Modal · AlertDialog · Drawer · Popover · Tooltip · HoverCard