Dialog
A modal overlay that interrupts the page for a focused task or confirmation. It traps focus, locks page scroll, dims and blocks everything behind it, and returns focus to the trigger on close — with the anatomy composed from parts you arrange.Import
import { Dialog } from "@hope-ui/components/dialog";Dialog is a compound component — a namespace object whose parts you arrange yourself; there is
no auto-composed shortcut. See the Anatomy below for every part and how they nest.
Usage
Dialog.Root owns the state (open/close, focus, ARIA) and renders no element of its own.
Dialog.Trigger opens the dialog; everything inside Dialog.Portal renders into a portal at the
end of <body>, above the rest of the page. A corner ✕ (Dialog.CloseTrigger) is added
automatically.
const [open, setOpen] = createSignal(false);
<Dialog.Root open={open()} onOpenChange={setOpen}>
<Dialog.Trigger render={(p) => <Button {...p}>Delete project</Button>} />
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete project</Dialog.Title>
<Dialog.Description>
This permanently deletes Acme Marketing Site and everything inside it.
</Dialog.Description>
</Dialog.Header>
<Dialog.Body>
<p>Removed for every member of the workspace. This action cannot be undone.</p>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button colorScheme="danger" onClick={() => setOpen(false)}>
Delete project
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Portal>
</Dialog.Root>;Dialog.CloseTrigger renders a themed CloseButton), a Dialog needs a <ThemeProvider> ancestor fed a preset — as every styled hope-ui component does.Anatomy
The structure is a fixed nesting, modeled on the Base UI / Ark / Chakra shape. Portal renders
the pointer-blocking modal layer and portals its children to <body>; Positioner is the fixed,
full-viewport frame that places the card and is the scroll container; Content is the card itself.
| Part | Element | Description |
|---|---|---|
Dialog.Root | — | State owner: open/close, focus, ids, and the resolved recipe. Renders no host element — just a context provider. |
Dialog.Trigger | button | Opens the dialog. Wires aria-haspopup / aria-expanded / aria-controls. Usually rendered as your own Button via render. |
Dialog.Portal | — | Portals Backdrop + Positioner to document.body (client-only) and renders the pointer-blocking modal layer beneath them. |
Dialog.Backdrop | div | The decorative scrim that dims the page and fades in/out. Optional — the modal layer does the pointer blocking either way. |
Dialog.Positioner | div | The fixed, full-viewport frame that positions the card (placement) and is the viewport scroll container. |
Dialog.Content | div | The card. Owns the focus trap, dismissal, aria-modal, and the ARIA role. Renders the auto corner ✕. |
Dialog.Header | div | Layout container for the title and description. |
Dialog.Body | div | The main content region. Scrolls under scrollBehavior=inside. |
Dialog.Footer | div | The action row — a sunken bar that right-aligns its buttons on wider viewports. |
Dialog.Title | h2 | The heading. Self-registers its id as the dialog's aria-labelledby. |
Dialog.Description | p | The supporting prose. Self-registers its id as the dialog's aria-describedby. |
Dialog.CloseTrigger | button | A CloseButton wired to close the dialog. Rendered automatically in the corner unless showCloseButton is false. |
Open state
Drive open as a controlled signal and handle onOpenChange to react to every close path — the
trigger, the corner ✕, Escape, an outside click, and your own footer buttons all route through it.
State: closed
const [open, setOpen] = createSignal(false);
<Dialog.Root open={open()} onOpenChange={setOpen}>
{/* … */}
</Dialog.Root>;Left uncontrolled, a Dialog manages its own open state: Dialog.Trigger opens it, and the corner ✕,
Escape, or an outside click close it. Set defaultOpen to have it start open.
<Dialog.Root defaultOpen>
<Dialog.Trigger render={(p) => <Button {...p}>Open</Button>} />
{/* … */}
</Dialog.Root>Sizes
The size prop scales the card. xs…xl widen a centered card; cover fills the viewport minus a
margin (keeping the corner radius) and full goes edge-to-edge with no radius — both ignore
placement. The default is md.
<Dialog.Root size="sm">…</Dialog.Root>
<Dialog.Root size="lg">…</Dialog.Root>
<Dialog.Root size="cover">…</Dialog.Root>
<Dialog.Root size="full">…</Dialog.Root>Placement
placement positions the card within the viewport frame: center (the default) centers it, and
top anchors it near the top. cover and full sizes fill the frame and ignore this axis.
<Dialog.Root placement="center">…</Dialog.Root>
<Dialog.Root placement="top">…</Dialog.Root>Scroll behavior
When content is taller than the viewport, scrollBehavior decides what scrolls. inside (the
default) caps the card height and scrolls the body, so the header and footer stay pinned;
outside lets the whole card scroll within the viewport at its natural height.
<Dialog.Root scrollBehavior="inside">…</Dialog.Root>
<Dialog.Root scrollBehavior="outside">…</Dialog.Root>The close button
Dialog.Content renders a Dialog.CloseTrigger in the corner automatically. Pass
showCloseButton={false} to drop it (as the alert dialog and non-dismissible examples below do), then
place your own Dialog.CloseTrigger anywhere inside the content for full control over its position
and label.
<Dialog.Content showCloseButton={false}>
<Dialog.CloseTrigger size="sm" />
{/* … */}
</Dialog.Content>Dialog.CloseTrigger is a CloseButton with the dialog's close wiring
pre-attached — it inherits every CloseButton prop (size, icon, class, render). Its close
handler runs in front of your own onClick, so calling event.preventDefault() cancels the close.
Dismissal
By default a Dialog light-dismisses: Escape closes it and a pointer-down outside the card
closes it. Turn either off with closeOnEscape and closeOnInteractOutside when the user must make
a deliberate choice.
<Dialog.Root closeOnEscape={false} closeOnInteractOutside={false}>
{/* only a footer button closes it */}
</Dialog.Root>Modality
A Dialog is modal by default: it traps focus inside the card, locks page scroll, sets
aria-modal, hides the rest of the page from assistive technology, and blocks pointer interaction
behind it. Set modal={false} for a non-modal overlay — still dismissable and still focus-restoring,
but the page behind stays interactive and scrollable.
<Dialog.Root modal={false}>…</Dialog.Root>Dialog.Content lives inside Dialog.Positioner. An unpositioned popup paints beneath the pointer-blocking modal layer and becomes unclickable.Alert dialog
For a destructive confirmation, set role="alertdialog" on Dialog.Root. This is the WAI-ARIA APG
alert-dialog pattern: the role is threaded to Dialog.Content, and you typically drop the corner ✕
(showCloseButton={false}) so the explicit footer actions are the only way out.
<Dialog.Root role="alertdialog" open={open()} onOpenChange={setOpen}>
<Dialog.Trigger render={(p) => <Button colorScheme="danger" {...p}>Delete account</Button>} />
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content showCloseButton={false}>
<Dialog.Header>
<Dialog.Title>Delete your account?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setOpen(false)}>Keep account</Button>
<Button colorScheme="danger" onClick={() => setOpen(false)}>Delete account</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Portal>
</Dialog.Root>With a form
Put a <form> in Dialog.Body and link the footer's submit button to it with the native form
attribute — the button sits outside the <form> element but still submits it. On open, the focus
trap moves focus to the first field; Tab cycles within the card.
<Dialog.Body>
<form id="edit-profile-form" onSubmit={handleSubmit}>
{/* fields */}
</form>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" type="button" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form="edit-profile-form">
Save changes
</Button>
</Dialog.Footer>Polymorphism
Render a part as a different element or component with the render prop — a function that receives
the computed props and spreads them onto your element. This is how Dialog.Trigger becomes your own
Button. There is no as prop; render is the single polymorphism API.
<Dialog.Trigger render={(props) => <Button {...props}>Open</Button>} /><Dialog.Positioner render={(props) => <section {...props} />} />Theming
Dialog's look comes from the active preset's recipe. Dialog is a neutral container — it has no
color axis; role accents belong on the footer's action Button, not the chrome. Override styling at
three levels, applied in order (later wins a Tailwind conflict): recipe base → preset slotClasses
→ instance slotClasses / part class.
Parts
Every part carries a data-slot attribute you can target, and each is addressable by name through
slotClasses. There is no root slot — Dialog.Root renders no element.
| Slot | data-slot | Description |
|---|---|---|
backdrop | dialog-backdrop | The dimming scrim (fades in/out). |
positioner | dialog-positioner | The fixed full-viewport frame + scroll container. |
content | dialog-content | The card surface. |
header | dialog-header | The title/description column. |
body | dialog-body | The main content region. |
footer | dialog-footer | The action-button bar. |
title | dialog-title | The heading line. |
description | dialog-description | The supporting prose. |
closeTrigger | dialog-close-trigger | Placement of the corner ✕ (its chrome comes from CloseButton). |
Overriding one dialog
Set slotClasses on Dialog.Root to reach any slot from one place, or put class on an individual
part. Use literal class strings so your Tailwind build can see them.
<Dialog.Root slotClasses={{ content: "max-w-md rounded-none", footer: "bg-transparent" }}>
{/* … */}
</Dialog.Root>
// …or per part:
<Dialog.Content class="max-w-md rounded-none">…</Dialog.Content>App-wide defaults & overrides
Set defaults and global part classes for every Dialog through the theme with definePreset, then
pass the derived preset to ThemeProvider. defaultProps resolve at instance ?? preset ??
builtin precedence, so an explicit prop on a single dialog always wins.
import { definePreset } from "@hope-ui/theming";
import { hope } from "@hope-ui/presets/hope";
export const myPreset = definePreset(hope, {
components: {
dialog: {
// Every recipe variant is defaultable.
defaultProps: { size: "sm", placement: "top" },
// Global part classes, folded in before per-instance slotClasses.
slotClasses: { content: "rounded-none" },
},
},
});Defaultable through defaultProps: size, placement, scrollBehavior, and role. Per-usage
props — open, defaultOpen, modal, the closeOn* flags, and event handlers — are intentionally
not defaultable app-wide.
API
Dialog.Root
Dialog.Root renders no host element, so it takes no native attributes — only the props below.
| Prop | Default | Type |
|---|---|---|
size | "md" | DialogSizeCard size: xs, sm, md, lg, xl, cover, full. cover/full fill the viewport and ignore placement. |
placement | "center" | DialogPlacementPosition within the viewport frame: center or top. |
scrollBehavior | "inside" | DialogScrollBehaviorWhat scrolls when content overflows: inside (body scrolls, header/footer pinned) or outside (whole card scrolls). |
role | "dialog" | "dialog" | "alertdialog"ARIA role, threaded to Content. Use alertdialog for a destructive confirmation (APG pattern). |
open | — | booleanControlled open state. Leave unset for uncontrolled (defaults to defaultOpen). |
defaultOpen | false | booleanInitial open state when uncontrolled. |
onOpenChange | — | (open: boolean) => voidCalled on every open/close request (trigger, ✕, Escape, outside click). |
modal | true | booleanTrap focus, lock scroll, set aria-modal, hide the page from AT, and block pointer behind. false keeps the page interactive. |
closeOnEscape | true | booleanWhether Escape closes the dialog. |
closeOnInteractOutside | true | booleanWhether a pointer-down outside the card closes the dialog. |
bubbles | false | boolean | { escapeKey?: boolean; outsidePress?: boolean }Whether an Escape or outside pointer-down consumed by a layer opened above the dialog (a Popover, a Menu) also closes the dialog. Off for both channels: the topmost layer alone dismisses, so the first Escape closes the popover and the second closes the dialog. |
slotClasses | — | SlotClasses<"dialog">Per-slot class overrides, keyed by backdrop, positioner, content, header, body, footer, title, description, closeTrigger. |
children | — | JSX.ElementThe dialog anatomy (Trigger + Portal > …). |
Dialog.Content
| Prop | Default | Type |
|---|---|---|
showCloseButton | true | booleanAuto-render a corner Dialog.CloseTrigger before your children. false to drop it and place your own. |
render | — | (props) => JSX.ElementRender Content as another element or component. Receives Content's computed props. |
class | — | stringExtra classes merged onto the content slot (your utilities win conflicts). |
Plus every native <div> attribute.
Dialog.Portal
| Prop | Default | Type |
|---|---|---|
mount | document.body | ElementWhere to portal the Backdrop and Positioner. |
children | — | JSX.ElementThe Backdrop + Positioner subtree. |
Parts
Dialog.Trigger, Dialog.Backdrop, Dialog.Positioner, Dialog.Header, Dialog.Body,
Dialog.Footer, Dialog.Title, and Dialog.Description each accept their native element attributes
plus a render prop and a class. Dialog.CloseTrigger accepts every
CloseButton prop; its close handler is pre-wired (call
event.preventDefault() in your own onClick to cancel the close).
Accessibility
Dialog implements the WAI-ARIA dialog (modal)
pattern; role="alertdialog" switches to the alert-dialog variant.
- Focus is trapped and restored. On open, focus moves into the card (the first focusable element); Tab and Shift+Tab cycle within it and cannot escape to the page behind. On close, focus returns to the element that opened the dialog.
- The rest of the page is hidden and inert. A modal dialog marks everything outside the card
aria-hidden+inertand blocks the pointer, so assistive tech and mouse both stay within the dialog. - It has an accessible name and description.
Dialog.Titleself-registers asaria-labelledbyandDialog.Descriptionasaria-describedby. Always include aDialog.Title; add aDialog.Descriptionwhen a supporting sentence helps. - Escape and outside click dismiss by default. Keep them on unless the task genuinely requires a deliberate choice; when you disable them, provide an obvious in-dialog way out.
- Use
alertdialogfor destructive confirmations. It tells assistive tech the dialog conveys an important message needing a response, and pairs well with dropping the corner ✕. - The close button is labeled.
Dialog.CloseTriggeris aCloseButton, which self-labels with a localized"Close"accessible name and is fully keyboard-operable (Enter and Space). - Motion is reduced-motion aware. The zoom-and-fade transition drops to an instant open/close when
the user has
prefers-reduced-motionenabled.