hope-uiearly preview

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 close button (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 in it.
          </Dialog.Description>
        </Dialog.Header>
        <Dialog.Body>
          <p>Removed for everyone in the workspace. This 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>;

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.

Dialog.Root
├── Dialog.Trigger
└── Dialog.Portal
├── Dialog.Backdrop
└── Dialog.Positioner
└── Dialog.Content
├── Dialog.CloseTrigger auto · showCloseButton
├── Dialog.Header
│ ├── Dialog.Title
│ └── Dialog.Description
├── Dialog.Body
└── Dialog.Footer
PartElementDescription
Dialog.RootNoneState owner: open/close, focus, ids, and the resolved recipe. Renders no host element — a context provider and nothing more.
Dialog.TriggerbuttonOpens the dialog. Wires aria-haspopup / aria-expanded / aria-controls. Normally rendered as your own Button via render.
Dialog.PortalNonePortals Backdrop + Positioner to document.body (client-only) and renders the pointer-blocking modal layer beneath them.
Dialog.BackdropdivThe decorative scrim that dims the page and fades in/out. Optional — the modal layer does the pointer blocking either way.
Dialog.PositionerdivThe fixed, full-viewport frame that positions the card (placement) and is the viewport scroll container.
Dialog.ContentdivThe card. Owns the focus trap, dismissal, aria-modal, and the ARIA role. Renders the automatic corner close button.
Dialog.HeaderdivLayout container for the title and description.
Dialog.BodydivThe main content region. Scrolls under scrollBehavior=inside.
Dialog.FooterdivThe action row — a sunken bar that aligns its buttons to the inline end on wider viewports.
Dialog.Titleh2The heading. Self-registers its id as the dialog’s aria-labelledby.
Dialog.DescriptionpThe supporting prose. Self-registers its id as the dialog’s aria-describedby.
Dialog.CloseTriggerbuttonA 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 close button, 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 close button, Escape, or an outside click close it. Set defaultOpen to have it start open.

Uncontrolled
<Dialog.Root defaultOpen>
  <Dialog.Trigger render={(p) => <Button {...p}>Open</Button>} />
  {/* … */}
</Dialog.Root>

Sizes

The size prop scales the card. xsxl 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.

Opt out, place your own
<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. It still dismisses and still restores focus, but the page behind stays interactive and scrollable.

<Dialog.Root modal={false}>…</Dialog.Root>

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 dropping the corner close button (showCloseButton={false}) leaves the explicit footer actions as 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.

Trigger as a Button
<Dialog.Trigger render={(props) => <Button {...props}>Open</Button>} />
Positioner as a section
<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.

Part slots

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.

Slotdata-slotDescription
backdropdialog-backdropThe dimming scrim (fades in/out).
positionerdialog-positionerThe fixed full-viewport frame + scroll container.
contentdialog-contentThe card surface.
headerdialog-headerThe title/description column.
bodydialog-bodyThe main content region.
footerdialog-footerThe action-button bar.
titledialog-titleThe heading line.
descriptiondialog-descriptionThe supporting prose.
closeTriggerdialog-close-triggerPlacement of the corner close button (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.

Every slot, from Root
<Dialog.Root
  slotClasses={{ content: "max-w-md rounded-none", footer: "bg-transparent" }}
>
  {/* … */}
</Dialog.Root>
One part
<Dialog.Content class="max-w-md rounded-none">…</Dialog.Content>

App-wide defaults and 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.

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

PropDefaultType
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).
defaultOpenfalse
booleanInitial open state when uncontrolled.
onOpenChange
(open: boolean) => voidCalled on every open/close request (trigger, close button, Escape, outside click).
modaltrue
booleanTrap focus, lock scroll, set aria-modal, hide the page from AT, and block pointer behind. false keeps the page interactive.
closeOnEscapetrue
booleanWhether Escape closes the dialog.
closeOnInteractOutsidetrue
booleanWhether a pointer-down outside the card closes the dialog.
bubblesfalse
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

PropDefaultType
showCloseButtontrue
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

PropDefaultType
mountdocument.body
ElementWhere to portal the Backdrop and Positioner.
children
JSX.ElementThe Backdrop + Positioner subtree.

Part props

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 + inert and blocks the pointer, so assistive tech and mouse both stay within the dialog.
  • It has an accessible name and description. Dialog.Title self-registers as aria-labelledby and Dialog.Description as aria-describedby. Always include a Dialog.Title; add a Dialog.Description when 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 alertdialog for destructive confirmations. It tells assistive tech the dialog conveys an important message needing a response, and pairs well with dropping the corner close button.
  • The close button is labeled. Dialog.CloseTrigger is a CloseButton, 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-motion enabled.