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#

TSX
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#

TSX
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#

TSX
<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? }:

reasonWhat closed it
closeA Dialog.Close was activated.
cancelA Dialog.Cancel was activated.
escapeEscape — the native cancel event, or zero's dismiss layer for a non-modal dialog.
backdropA click on the ::backdrop.
programmaticEvery 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:

TSX
<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#

TSX
<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#

TSX
<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#

PartElementStatesFlagsNotes
triggerbuttonopen | closeddisabled, focus-visible, pressed, press-animatingaria-haspopup="dialog", aria-expanded, aria-controls. Carries the variant axes. asChild.
popupdialogopen | closed—aria-labelledby / aria-describedby while a Title / Description is rendered.
backdroppseudoopen | closed—Renders no element on the web; projects onto popup::backdrop.
titleh2——Inside popup. Offers visuallyHidden (data-visually-hidden).
descriptionp——Inside popup.
footerfooter——The action row. Inside popup.
closebutton—disabled, focus-visible, pressed, press-animatingCloses the dialog. asChild.
cancelbutton—disabled, focus-visible, pressed, press-animatingThe 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#

PropTypeDefaultDescription
modelboolean—Two-way binding of the open state.
defaultOpenbooleanfalseInitial state when uncontrolled.
openChangeevent (open: boolean)—Fires whenever the open state changes.
closeevent (detail: DialogCloseDetail)—Fires once per close, after openChange(false): { reason, value? }, reason one of 'close' | 'cancel' | 'escape' | 'backdrop' | 'programmatic' (DialogCloseReason).
modalbooleantrueshowModal() (focus trap, inert background, backdrop) or show().
dismissiblebooleantrueWhether Escape and, for a modal dialog, a backdrop click close it.
role'dialog' | 'alertdialog''dialog'The alert-dialog preset.

Dialog.Trigger#

PropTypeDefaultDescription
disabledbooleanfalseRenders data-disabled.
color / size / variant / axes / modsdesign-system vocabulary—The variant axes for the scope, rendered on the trigger.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstring—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#

PropTypeDefaultDescription
valuestring—Dialog.Close only. Reported as the close event's value; also rendered as the button's native value.
disabledbooleanfalseRenders data-disabled.
asChildbooleanfalseRender through the default slot.
classstring—Extra classes.

Keyboard and dismissal#

InteractionModalNon-modal
EscapeCloses, via the native cancel event, when dismissible.Closes through zero's dismiss layer, when dismissible.
Backdrop clickCloses 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.
TabTrapped inside the popup by the platform.Moves freely.
Focus on closeRestored 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.