Dialog
A modal dialog on the platform's own <dialog>: showModal() supplies the top layer, the backdrop, an inert page, Escape, focus containment and focus return, so none of it is written in JavaScript.
Import
JSX
import Dialog, { AlertDialog } from '@box-kite/react/components/dialog';Usage
JSX
<Dialog trigger={(t) => <Button {...t}>Rename</Button>}>
<Dialog.Title>Rename this view</Dialog.Title>
<Dialog.Description>The name is only shown to you.</Dialog.Description>
<Textbox name="name" props={{ 'aria-label': 'Name' }} />
</Dialog>What the platform does, so this does not
Every accessible-dialog library ships a focus trap, a scroll lock, an Escape handler and a stacking-order strategy.
showModal() is all four, and they are the browser's:The top layer, and an inert page
A modal dialog paints above every stacking context and outside every clipped ancestor, and everything behind it becomes
inert — unclickable, untabbable and skipped by a screen reader. No z-index, no aria-hidden sweep over the page.Focus containment, in and out
Opening runs the dialog focusing steps — an
autofocus inside, else the first focusable thing — Tab cycles inside and never leaves, and closing puts focus back where it came from. initialFocus is the one addition, because APG asks an alert dialog to land on its least destructive action.Escape, and a press outside
Escape is the platform's close request. A press outside is
closedby="any", which the component writes for you — and where the browser does not have it yet, it measures the press against the dialog's own box, because for a modal dialog the backdrop is the dialog element and every containment test calls it inside.A title names it, a description describes it
role="dialog" has no name of its own, and a dialog without one is announced as a group of orphaned content. Rendering a Dialog.Title is what puts aria-labelledby on the dialog, so the name and the visible heading cannot drift apart; a Dialog.Description does the same for aria-describedby. Neither attribute is written when the part is absent — a reference pointing at nothing is worse than no reference.JSX
// From its own parts, which is the usual way.
<Dialog>
<Dialog.Title>Rename this view</Dialog.Title>
<Dialog.Description>The name is only shown to you.</Dialog.Description>
</Dialog>
// From the page, or from a string, when the dialog shows no heading.
<Dialog labelledBy="section-heading" describedBy="section-hint" />
<Dialog label="Rename this view" />The dialog is always rendered
Closed means
display: none, not unmounted — the same shape as <Popover>, and what lets the browser own showing and hiding. So the exit is a plain CSS transition rather than a <Presence>: the entrance is startingStyle and the way out is transitionBehavior="allow-discrete", both already in the component's styles, and both applying to the ::backdrop as well.The cost is that the children render whether or not anyone has opened it. Gate an expensive one yourself:
JSX
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen} label="Report" trigger={(t) => <Button {...t}>Report</Button>}>
{open ? <Box>The expensive part</Box> : null}
</Dialog>Controlled, and why a close cannot be refused
Leave
open out and the dialog owns its state. Pass it and you own it, with one asymmetry that comes from the platform: the cancel event is cancelable, but by the time onOpenChange runs the browser has already closed the dialog. Keeping open true shows it again rather than arguing — and if a decision must not be dismissed, that is dismissible={false}, which is what <AlertDialog> is.Every change names its reason:
trigger, escape, outside-pointer or imperative — the last being everything else, a close() call and a <form method="dialog"> submit included.Last reason:
—AlertDialog: a decision that cannot be clicked away
role="alertdialog" tells a screen reader the content is an alert rather than a panel, so the name and the description are announced together the moment it opens. It is always modal, a press outside is ignored on purpose, and Escape still closes it — a keyboard user must always have a way out.JSX
const cancel = useRef(null);
<AlertDialog initialFocus={cancel} trigger={(t) => <Button {...t}>Delete</Button>}>
<AlertDialog.Title>Delete this view?</AlertDialog.Title>
<AlertDialog.Description>Nothing here can be undone.</AlertDialog.Description>
<Button bgColor="rose-600">Delete</Button>
<Button ref={cancel}>Cancel</Button>
</AlertDialog>initialFocus puts focus on Cancel, so a deletion cannot be confirmed by reflex.It takes every Dialog prop but three
modal, dismissible and lockScroll are not choices an alert dialog gets to make. Everything else — the trigger, the open state, the naming, initialFocus and every Box prop — is the same, and so is the style tree.The page behind it
A modal dialog stops the page scrolling; a non-modal one does not. That is
lockScroll, which defaults to whatever modal is, and it is a prop because blocking the page unasked is the commonest complaint about every library that does. The lock is a class carrying overflow: hidden, held by a counter, so an inner dialog closing does not unlock the page under an outer one.Two things worth knowing: the scrollbar's width leaves the page as the lock is applied, so a document that should not shift wants
scrollbarGutter="stable" on its root; and iOS Safari scrolls anyway, where nothing short of position: fixed on the body holds it.Styling
The dialog is a Box, so every prop is available on it, and the defaults live in
Box.components('dialog') — with dialog.title and dialog.description for the two parts. The style tree says nothing about position or size on purpose: the browser's own stylesheet centres a modal dialog in the viewport and caps it at calc(100% - 6px - 2em), which is better than anything a default could express.JSX
<Dialog p={8} maxWidth={120} borderRadius={4} backdrop={{ bgColor: 'indigo-950/60' }}>
<Dialog.Title fontSize={24}>Any Box prop, the backdrop included</Dialog.Title>
</Dialog>Dialog props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
trigger | (trigger: DialogTrigger) => React.ReactNode | — | The control that opens it, handed the ref and props that wire it up. Leave it out to open the dialog from anywhere else. |
children | React.ReactNode | — | The dialog's content — a Dialog.Title, usually a Dialog.Description, and the controls. |
open | boolean | — | Controlled open state. Leave it out and the dialog owns it. |
defaultOpen | boolean | — | Whether it starts open, when the dialog owns its own state. |
onOpenChange | ChangeHandler<boolean, DialogReason> | — | Fires with the new state and why it changed — 'trigger', 'escape', 'outside-pointer' or 'imperative'. A close cannot be refused: the browser has already done it by the time this runs. |
label | string | — | The dialog's accessible name, when no Dialog.Title supplies one. A dialog without a name is unusable. |
labelledBy | string | — | Names the dialog after an element already on the page, instead of label or a Dialog.Title. |
describedBy | string | — | Describes it from an element already on the page, instead of a Dialog.Description. |
modal | boolean | — | Whether the rest of the page is blocked while it is open. Default true, which is showModal(): the top layer, a ::backdrop, an inert page, Escape and focus containment, all the browser's. |
dismissible | boolean | — | Whether a press outside closes it. Default true — false is what makes a decision unavoidable. |
lockScroll | boolean | — | Whether the page behind it stops scrolling. Default: whatever modal is, because a modal dialog that scrolls the page behind it is the commonest complaint about every library that blocks it unasked. |
initialFocus | ElementLike | — | Where focus lands on open, instead of the first focusable thing inside. An **alert dialog owes this to its least destructive action** — APG's rule, so that a keyboard user cannot confirm a deletion by reflex. An autofocus attribute inside does the same job with no ref. |
Dialog.Title
The dialog's heading, and what names it: rendering one is what puts
aria-labelledby on the dialog, so a name and a visible title cannot drift apart. An <h2> by default — tag takes any of the six.| Prop | Type | Default | What it does |
|---|
Dialog.Description
The dialog's supporting text, and what describes it: rendering one puts
aria-describedby on the dialog.| Prop | Type | Default | What it does |
|---|
Dialog keyboard
| Key | Result |
|---|---|
Escape | Closes it, from anywhere inside, and returns focus to the trigger. |
Tab | Cycles focus inside a modal dialog and never leaves it; in a non-modal one it moves on. |
Enter, Space | On the trigger, opens the dialog. |
Dialog accessibility
role="dialog"(alertdialogon<AlertDialog>), named by aDialog.Title,labelorlabelledBy, and described by aDialog.DescriptionordescribedBy.- The trigger carries
aria-haspopup="dialog",aria-expandedandaria-controls. - Focus is contained while a modal dialog is open and returns to the trigger when it closes, both from the platform.
initialFocusis what an alert dialog needs to land on its least destructive action.
Swept with axe on every release, in this state:
Dialog (open). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Dialog style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.dialogdialog.titledialog.descriptionAlertDialog props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
AlertDialog adds no props of its own — it is a Box with one thing decided for it.AlertDialog keyboard
| Key | Result |
|---|---|
Escape | Closes it and returns focus to the trigger. |
Tab | Cycles focus inside it and never leaves. |
AlertDialog accessibility
role="alertdialog", which tells a screen reader the content is an alert rather than a panel, so the name and description are announced together on open.- A press outside is ignored on purpose; Escape is not, so the dialog is never a trap.
Swept with axe on every release, in this state:
AlertDialog (open). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.