# Dialog

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/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.

Press Escape, or click outside the dialog.

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>
```

## Delete this view?

Nothing here can be undone.

`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](https://www.box-kite.dev/box.md) 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"` (`alertdialog` on `<AlertDialog>`), named by a `Dialog.Title`, `label` or `labelledBy`, and described by a `Dialog.Description` or `describedBy`.
- The trigger carries `aria-haspopup="dialog"`, `aria-expanded` and `aria-controls`.
- Focus is contained while a modal dialog is open and returns to the trigger when it closes, both from the platform. `initialFocus` is 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.

`dialog`

`dialog.title`

`dialog.description`

## AlertDialog props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) 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.

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

