Dialog
A modal (or non-modal) overlay on the native <dialog> element. The top layer
replaces any Portal: the server renders the popup closed in place, and showModal() gives
focus trapping, Escape, an inert background and focus restore natively. State flows one way —
the model opens and closes the element, and native close events sync back.
Import
import { Dialog } from '@sigx/zero/dialog';
Dialog is a compound: Dialog.Root, Dialog.Trigger, Dialog.Popup, Dialog.Title,
Dialog.Description, Dialog.Footer, Dialog.Close, Dialog.Cancel. It is also
re-exported from the @sigx/zero root, together with dialogAnatomy and useDialogContext.
Usage
import { component } from 'sigx';
import { Dialog } from '@sigx/zero/dialog';
const Invite = component(({ signal }) => {
const state = signal({ open: false });
return () => (
<Dialog.Root model={() => state.open}>
<Dialog.Trigger color="primary">Invite a teammate</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>Invite a teammate</Dialog.Title>
<Dialog.Description>They get an email with a link that works once.</Dialog.Description>
…
<Dialog.Footer>
<Dialog.Close>Cancel</Dialog.Close>
<button type="button" onClick={send}>Send invite</button>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
);
});
Dialog.Root renders no element of its own — it only provides context — so the trigger and
the popup can sit anywhere in the tree below it. The popup is rendered in place and lifted
into the top layer when it opens; there is no Portal and no z-index to manage.
Alert dialog
<Dialog.Root model={() => state.confirmDelete} role="alertdialog">
<Dialog.Trigger color="error">Delete project</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>Delete this project?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Footer>
<Dialog.Cancel>Keep it</Dialog.Cancel>
<button type="button" onClick={destroy}>Delete</button>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
role="alertdialog" is the APG alert-dialog preset on the same anatomy: the popup announces
as alertdialog, a backdrop click does not dismiss (Escape still does, under
dismissible), and initial focus goes to the least-destructive action — mark it with
Dialog.Cancel, which closes like Close and carries autofocus in this mode so
showModal()'s focusing steps land on it.
Why it closed
openChange(false) says that the dialog closed; the close event that follows it says
why. It fires once per close, right after openChange(false), with a
DialogCloseDetail — { reason, value? }:
reason | What closed it |
|---|---|
close | A Dialog.Close was activated. |
cancel | A Dialog.Cancel was activated. |
escape | Escape — the native cancel event, or zero's dismiss layer for a non-modal dialog. |
backdrop | A click on the ::backdrop. |
programmatic | Every close zero did not start: the parent writing the model, a native close(), a <form method="dialog"> submission. |
value is the closing Dialog.Close's value prop — the <button value> of a
<form method="dialog">, as a prop. For a native close zero did not start, it is the
element's non-empty returnValue instead (reset on every open, so a stale one never reads as
the current close's). A controlled parent that refuses the close — its model stays true —
gets no close event: only a close that took is reported.
A confirm dialog reads the answer off the close instead of keeping a flag beside its model:
<Dialog.Root model={() => state.open} onClose={(d) => { if (d.value === 'confirm') archive(); }}>
<Dialog.Trigger>Archive…</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>Archive this project?</Dialog.Title>
<Dialog.Footer>
<Dialog.Cancel>Keep it</Dialog.Cancel>
<Dialog.Close value="confirm">Archive</Dialog.Close>
</Dialog.Footer>
</Dialog.Popup>
</Dialog.Root>
value also renders as the button's native value attribute, so an asChild <button>
keeps the <form method="dialog"> semantics. A submit button of your own inside a
<form method="dialog"> closes the dialog through the platform: the event then reports
programmatic, with the submitter's value from returnValue.
A title only assistive technology reads
<Dialog.Title visuallyHidden>Keyboard shortcuts</Dialog.Title>
visuallyHidden keeps the title in the accessibility tree — it still names the popup through
aria-labelledby — and takes it off the screen. See
Accessibility.
Non-modal
<Dialog.Root model={() => state.open} modal={false}>
modal={false} opens with show() instead of showModal(): the dialog stays in flow, the
page behind it stays interactive, and there is no backdrop. A non-modal <dialog> fires no
cancel event, so zero routes Escape through its own dismiss layer and restores focus on
close itself.
Anatomy
| Part | Element | States | Flags | Notes |
|---|---|---|---|---|
trigger | button | open | closed | disabled, focus-visible, pressed, press-animating | aria-haspopup="dialog", aria-expanded, aria-controls. Carries the variant axes. asChild. |
popup | dialog | open | closed | — | aria-labelledby / aria-describedby while a Title / Description is rendered. |
backdrop | pseudo | open | closed | — | Renders no element on the web; projects onto popup::backdrop. |
title | h2 | — | — | Inside popup. Offers visuallyHidden (data-visually-hidden). |
description | p | — | — | Inside popup. |
footer | footer | — | — | The action row. Inside popup. |
close | button | — | disabled, focus-visible, pressed, press-animating | Closes the dialog. asChild. |
cancel | button | — | disabled, focus-visible, pressed, press-animating | The least-destructive action; autofocus under alertdialog. asChild. |
Dialog is one of the four scopes (with Menu, Popover and Tooltip) whose Root renders a
fragment, so there is no root part. The trigger is the carrier part: the variant axes
live on Dialog.Trigger, and a design system's axis rules for the scope anchor there. The
popup is a top-layer sibling of the trigger, so colour styles the trigger, never the popup.
The backdrop is a real part of the anatomy that renders no element on the web: recipes style
parts.backdrop and the compiler projects it onto
[data-scope="dialog"][data-part="popup"]::backdrop, with states narrowing the popup
([data-state="open"]::backdrop). A platform without a native top layer renders it as an
element; sharing the part name is what lets one recipe style both.
ARIA wiring is presence-aware: the popup references its Title and Description ids only
while those parts are actually rendered, so omitting a title never leaves a dangling
aria-labelledby.
Props
Dialog.Root
| Prop | Type | Default | Description |
|---|---|---|---|
model | boolean | — | Two-way binding of the open state. |
defaultOpen | boolean | false | Initial state when uncontrolled. |
openChange | event (open: boolean) | — | Fires whenever the open state changes. |
close | event (detail: DialogCloseDetail) | — | Fires once per close, after openChange(false): { reason, value? }, reason one of 'close' | 'cancel' | 'escape' | 'backdrop' | 'programmatic' (DialogCloseReason). |
modal | boolean | true | showModal() (focus trap, inert background, backdrop) or show(). |
dismissible | boolean | true | Whether Escape and, for a modal dialog, a backdrop click close it. |
role | 'dialog' | 'alertdialog' | 'dialog' | The alert-dialog preset. |
Dialog.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Renders data-disabled. |
color / size / variant / axes / mods | design-system vocabulary | — | The variant axes for the scope, rendered on the trigger. |
asChild | boolean | false | Render through the default slot, which receives the part bag. |
class | string | — | Extra classes. |
Dialog.Popup, Dialog.Title, Dialog.Description, Dialog.Footer
class. Dialog.Title also takes visuallyHidden (boolean), which renders
data-visually-hidden and keeps the title as the popup's accessible name while it is off
screen.
Dialog.Close, Dialog.Cancel
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Dialog.Close only. Reported as the close event's value; also rendered as the button's native value. |
disabled | boolean | false | Renders data-disabled. |
asChild | boolean | false | Render through the default slot. |
class | string | — | Extra classes. |
Keyboard and dismissal
| Interaction | Modal | Non-modal |
|---|---|---|
| Escape | Closes, via the native cancel event, when dismissible. | Closes through zero's dismiss layer, when dismissible. |
| Backdrop click | Closes when dismissible and role is dialog; a click on the dialog's own padding is not a backdrop click. | No backdrop; clicks elsewhere are not an event. |
| Tab | Trapped inside the popup by the platform. | Moves freely. |
| Focus on close | Restored by the platform. | Restored by zero. |
In the shipped design systems
@sigx/zero-basic and @sigx/zero-daisyui both wire color (the eight recommended roles)
and size (xs–xl) on the dialog trigger, in each skin's own button idiom. Neither
wires a variant on the scope. The popup, backdrop, title, description, footer and close
parts are styled by every shipped design system — including the open / closed
transitions on popup and the backdrop scrim.
