# Dialog

## Summary

Dialog is a modal surface that presents a single, focused task or decision above
the page. On large screens it appears as a centered modal; on small screens it
appears as a sheet anchored to the bottom of the screen.

## Anatomy

| Part       | Description                                                         |
| ---------- | ------------------------------------------------------------------- |
| Trigger    | An interactive element that opens the dialog                        |
| Content    | The floating panel (modal) or sheet that contains the dialog        |
| Header     | Layout slot at the top, commonly holding the title and close button |
| Title      | Primary heading of the dialog, wired to the accessible name         |
| Close      | The dismiss control that closes the dialog                          |
| Body       | The main content region, and the default scroll area                |
| Footer     | Layout slot at the bottom, commonly holding the actions             |
| Actions    | Optional: the primary, secondary, and tertiary action button layout |
| ScrollArea | Optional: advanced override for which region scrolls versus pins    |

## Behavior

#### Opening and closing

The dialog opens when:

* The trigger is clicked or tapped
* The trigger is activated by keyboard (e.g. via the Enter or Space keys)

The dialog closes when:

* The close button is selected
* An action that resolves the task is selected
* A click or tap occurs outside the dialog (on the backdrop)
* The ESC key is pressed

An `alertdialog` is the exception: it ignores outside and backdrop dismissal, so
the user must choose an action to resolve it. ESC still closes it.

#### Scrolling

The `Dialog.Body` is the default scroll region, while the header and footer stay
pinned as the content scrolls. Which region scrolls is decided by composition,
not a prop — wrap parts in a `Dialog.ScrollArea` when a header or footer should
scroll with the content instead of pinning.

#### Nested dialog

A dialog can open a second dialog, which layers on top of the first. The
underlying dialog stays in place behind the new overlay and returns when the top
one closes.

#### Small screens

When a user is on a small screen such as a mobile device, the dialog opens as a
sheet anchored to the bottom of the screen. The parts and accessibility wiring
are identical to the desktop modal.

#### Native mobile

Atlantis does not provide a native `Dialog` equivalent in
`@jobber/components-native`. For a comparable mobile-native experience, use
[`ContentOverlay`](/components/ContentOverlay) from `@jobber/components-native`
to present content or actions in a sheet.

## Variants

#### Type

**1. Dialog:** The default. Used for most tasks and content. Can be dismissed by
clicking outside, pressing ESC, or the close button.

**2. Alert dialog:** Used to interrupt the user and require a decision, such as
confirming a destructive action. It is always modal, cannot be dismissed by
clicking outside, and has no close button — the user must choose an action.

#### Size

| Size        | Max width | Use for                                                                   |
| ----------- | --------- | ------------------------------------------------------------------------- |
| Small       | 440px     | Short confirmations or simple choices                                     |
| Base        | 720px     | The default, suitable for most content                                    |
| Large       | 1032px    | Denser content or forms                                                   |
| Full screen | Viewport  | Fills the viewport on large screens; a full-height sheet on small screens |

Widths are max widths — the dialog narrows to fit smaller viewports.

#### Actions

`Dialog.Actions` arranges the action buttons: a tertiary/destructive action on
the left, and primary and secondary actions grouped on the right. The footer is
a generic region, so it can also hold a note, a checkbox, or a custom layout
instead.

#### Custom content

* The body supports flexible content layouts
* `fullBleed` removes body padding when content should reach the edges
* Header and footer are layout-only slots; they own arrangement, not typography
  or actions

## Content Guidelines

These build on the general [Voice & tone](../voice-and-tone/voice-and-tone.md) and
[Formatting](../formatting/formatting.md) guidelines.

A dialog interrupts the user, so every word should earn its place: name the
task, state the outcome, and give a clear way out.

#### Title

The title names the task as opposed to the component. Use verbs when the dialog
is asking the user to actually do something. Be specific. Use a noun when you're
presenting information. Don't phrase confirmation requests as questions.

| Do                 | Don't                                        |
| ------------------ | -------------------------------------------- |
| Delete client      | Are you sure you want to delete this client? |
| Edit job details   | Job Editor                                   |
| Assign team member | Assign                                       |
| Send invoice       | Send                                         |
| Client information | Info                                         |

#### Body

Body copy needs to expand on the title by providing what the title can't. It
shouldn't repeat the title and should provide useful information such as
explaining the impact of any action the user might be taking. For a routine
task, one short sentence is often enough.

| Do                                             | Don't                                                          |
| ---------------------------------------------- | -------------------------------------------------------------- |
| This will remove the discount from the invoice | This will let you remove the discount that's currently applied |

#### Actions

Primary action lables will need to lead with a verb and match the action taking
place. They shouldn't be generic such as "Confirm" or "Yes". The action needs to
be clearly intentional. Also, remember that buttons are title case.

| Do                  | Don't   |
| ------------------- | ------- |
| Cancel Subscription | Confirm |
| Sync QuickBooks     | Next    |
| Delete 3 Files      | Delete  |

#### Alert dialogs

As `alertdialog` can't be dismissed by clicking outside, the way out needs to be
clear. We should not be reliant on users pressing ESC. When relevant, cancel
actions need to be considered alonsgside confirmation actions and should be just
as easy to find. Ideally via secondary CTAs.

## Do's and Don'ts

Here are some general rules to follow when working with the dialog component:

#### Do:

* ✅ Give every dialog a clear title that names the task
* ✅ Keep a dialog focused on a single task or decision
* ✅ Use an alert dialog for destructive or irreversible actions, and always
  offer a way to cancel
* ✅ Lead with a short, action-oriented primary button label
* ✅ Choose the size that comfortably fits the content

#### Don't:

* ❌ Combine multiple unrelated tasks in one dialog
* ❌ Use a dialog for long or multi-step flows that belong on their own page
* ❌ Use a nested dialog unless the flow genuinely requires it
* ❌ Remove the ability to dismiss without offering an explicit action to leave

## Accessibility

Users need to be able to open the dialog, operate its content, and close it with
assistive technology.

#### Semantics

* The surface `role` is `dialog`, or `alertdialog` for the alert type
* `Dialog.Title` is auto-wired to `aria-labelledby`
* `Dialog.Body` is auto-wired to `aria-describedby`

#### Focus

* Focus is trapped within the dialog while it is open
* On close, focus returns to the trigger
* When the trigger is removed while the dialog is open, focus can be directed to
  the closest sensible element instead

#### Keyboard navigation

| Key            | Behavior                                             |
| -------------- | ---------------------------------------------------- |
| Tab            | Moves focus between focusable elements in the dialog |
| Enter or Space | Activates the focused control                        |
| Esc            | Closes the dialog                                    |

## Related components

* To trigger a single action rather than presenting a task or decision, use a
  [Button](../Button/Button.md) or [IconButton](/components/IconButton)
* For a comparable overlay experience in native mobile, use
  [BottomSheet](/components/BottomSheet) from `@jobber/components-native`
* To present a list of options or actions, use a [Menu](../Menu/Menu.md)


## Component customization

### Composable API

`Dialog` is composed from its building-block subcomponents rather than driven by
a single set of props: `Dialog.Trigger`, `Dialog.Content`, `Dialog.Header`,
`Dialog.Title`, `Dialog.Close`, `Dialog.Body`, `Dialog.Footer`,
`Dialog.Actions`, and `Dialog.ScrollArea`. This gives you control over the
dialog's layout while keeping the accessibility wiring automatic.

Here is a basic example:

```tsx
<Dialog>
  <Dialog.Trigger>
    <Button label="Open" />
  </Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Dialog title</Dialog.Title>
      <Dialog.Close />
    </Dialog.Header>
    <Dialog.Body>
      <Text>Body content goes here.</Text>
    </Dialog.Body>
    <Dialog.Footer>
      <Dialog.Actions
        primary={{ label: "Save", onClick: handleSave }}
        secondary={{ label: "Cancel", onClick: handleCancel }}
      />
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>
```

`Dialog.Header` and `Dialog.Footer` are layout-only slots — they own
arrangement, not typography or actions. `Dialog.Actions` is the action-button
layout (tertiary on the left; primary/secondary grouped on the right) and is
dropped inside a `Dialog.Footer`.

### Controlled open state

By default the dialog manages its own open state — the trigger opens it and the
close/actions dismiss it, with no state to wire up. To drive it yourself (for
example, to open it from elsewhere or to gate closing on a save), pass `open`
and handle `onRequestClose`. `onRequestClose` fires whenever the user asks to
close — Escape, a backdrop click, or the dismiss button — so a controlled dialog
should update its state there. The trigger's own control still opens the dialog
via its `onClick`.

```tsx
function ControlledDialog() {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onRequestClose={() => setOpen(false)}>
      <Dialog.Trigger>
        <Button label="Open" onClick={() => setOpen(true)} />
      </Dialog.Trigger>
      <Dialog.Content>
        <Dialog.Header>
          <Dialog.Title>Edit details</Dialog.Title>
          <Dialog.Close />
        </Dialog.Header>
        <Dialog.Body>
          <Text>Body content goes here.</Text>
        </Dialog.Body>
        <Dialog.Footer>
          <Dialog.Actions
            primary={{ label: "Save", onClick: () => setOpen(false) }}
            secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
          />
        </Dialog.Footer>
      </Dialog.Content>
    </Dialog>
  );
}
```

Because the state is yours, you can keep the dialog open when a save fails —
only call `setOpen(false)` once the action succeeds. When you don't need that
control, omit `open`/`onRequestClose` and let the dialog manage itself.

### Trigger and close customization

`Dialog.Trigger` and `Dialog.Close` accept Atlantis-style content and can render
a default control, composed Atlantis `Button` content, or a custom element via
`render`. An Atlantis `<Button>` is adapted automatically; any other valid
element is used as-is. When `render` is a function, it receives the Base UI
props so the element stays wired to open/close the dialog.

```tsx
{/* default primary Button trigger / default close Button */}
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Close />

{/* custom element */}
<Dialog.Trigger render={<Button type="tertiary" label="Open" />} />
<Dialog.Close render={<Button type="tertiary" label="Done" />} />

{/* render function */}
<Dialog.Close render={closeProps => <MyClose {...closeProps} />} />
```

### Detached triggers

When the trigger can't be nested inside the `Dialog` — for example an action
inside a `Combobox`, `Autocomplete`, or `Menu` overlay — connect it through a
`handle` from `Dialog.createHandle()` instead of nesting. The trigger can pass
`payload` data that is available to `Dialog`'s render-function `children`.

```tsx
const handle = Dialog.createHandle();

<Dialog handle={handle}>
  {({ payload }) => <Dialog.Content>{/* read payload here */}</Dialog.Content>}
</Dialog>;

<Dialog.Trigger handle={handle} payload={selectedClient}>
  Create client
</Dialog.Trigger>;
```

### Scroll and sticky model

Composition — not a `sticky` prop — decides what scrolls. The scroll region is a
real element and owns the scrollbar:

* Content **inside** the scroll region scrolls (the scrollbar lives here).
* Content **outside** the scroll region (a direct child of `Dialog.Content`)
  stays pinned.

`Dialog.Body` is the default scroll region, so a flat `Header`/`Body`/`Footer`
pins the header and footer and scrolls the body — the common case needs nothing
extra.

```tsx
<Dialog.Content>
  <Dialog.Header /> {/* pinned */}
  <Dialog.Body>…</Dialog.Body> {/* scrolls */}
  <Dialog.Footer /> {/* pinned */}
</Dialog.Content>
```

To make a header or footer scroll with the content instead of pinning, wrap the
parts that should scroll together in `Dialog.ScrollArea`. Anything inside it
scrolls; anything left as a sibling in `Dialog.Content` stays pinned.

```tsx
{
  /* sticky Header, scrolling Footer */
}
<Dialog.Content>
  <Dialog.Header /> {/* pinned */}
  <Dialog.ScrollArea>
    <Dialog.Body>…</Dialog.Body>
    <Dialog.Footer /> {/* inside → scrolls */}
  </Dialog.ScrollArea>
</Dialog.Content>;
```

The four sticky combinations map to composition:

| Sticky combination              | Composition                                          |
| ------------------------------- | ---------------------------------------------------- |
| Both sticky                     | `Header`, `Body`, `Footer` flat in `Content` (basic) |
| Sticky header, scrolling footer | `Header`, then `ScrollArea{ Body, Footer }`          |
| Scrolling header, sticky footer | `ScrollArea{ Header, Body }`, then `Footer`          |
| Nothing sticky                  | `ScrollArea{ Header, Body, Footer }`                 |

### Rendering the surface as a form

`Dialog.Content` accepts Base UI's `render` escape hatch. Pass
`render={<form onSubmit={…} />}` to make the surface itself a `<form>`, so a
submit button in `Dialog.Footer` submits it while the header and footer stay
pinned. For two or more independent forms in one dialog, give each
`<form id="…">` its own id in the body and point each submit control at it with
the native `form` attribute instead.

```tsx
<Dialog.Content render={<form onSubmit={handleSubmit} />}>
  <Dialog.Header>…</Dialog.Header>
  <Dialog.Body>{/* form fields */}</Dialog.Body>
  <Dialog.Footer>
    <Dialog.Actions primary={{ label: "Save", type: "submit" }} />
  </Dialog.Footer>
</Dialog.Content>
```

### Focus return when the trigger unmounts

Base UI returns focus on close to the trigger, then the element focused before
open — each only if it is still connected to the DOM. It never walks to a
neighbor. A common Jobber flow breaks both at once: a "Create…" action opens the
dialog, and on save the trigger is replaced by a card. By the time the dialog
closes, the trigger is gone and focus falls to `document.body`.

For that case, `finalFocus` accepts a function. Build it with
`getDialogReturnFocus({ trigger, boundary })`:

```tsx
const triggerRef = useRef<HTMLButtonElement>(null);
const boundaryRef = useRef<HTMLDivElement>(null);

<div ref={boundaryRef}>
  {created ? <Card>…</Card> : <Trigger ref={triggerRef} />}
</div>;

<Dialog
  handle={handle}
  finalFocus={getDialogReturnFocus({
    trigger: triggerRef,
    boundary: boundaryRef,
  })}
>
  …
</Dialog>;
```

Resolution order on close:

1. `trigger`, if it is still connected (the normal case);
2. otherwise the first focusable element inside `boundary` (typically the card
   that replaced the trigger);
3. otherwise `null`, letting Base UI fall back to its default behavior.

Keep `boundary` scoped tightly around the region that swaps. When the trigger
never unmounts, a plain `finalFocus={triggerRef}` is enough and this helper is
unnecessary.

### Alert dialog

`type="alertdialog"` is always modal, ignores outside/backdrop dismissal, and
omits the default `Dialog.Close` button — the user must choose an action. Escape
still closes it. Always include a cancel action so there is a genuine way out.

```tsx
<Dialog type="alertdialog">
  <Dialog.Trigger>Delete client</Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Delete client</Dialog.Title>
    </Dialog.Header>
    <Dialog.Body>
      <Text>
        Sarah Johnson and all her history will be removed. This can't be undone.
      </Text>
    </Dialog.Body>
    <Dialog.Footer>
      <Dialog.Actions
        primary={{ label: "Delete Client", onClick: handleDelete }}
        secondary={{ label: "Cancel", onClick: handleCancel }}
      />
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>
```

## Sizing

`size` accepts `"small"` (440px), `"base"` (720px, the default), `"large"`
(1032px), and `"fullScreen"`. The pixel values are max widths; the dialog
narrows to fit smaller viewports.

Use `size="fullScreen"` for complex workflows or dense layouts that need more
space without turning the task into a new route. On desktop it fills the
viewport with squared corners; on mobile the bottom sheet becomes a full-width,
full-height surface instead of the default peek.

Use `fullBleed` on `Dialog.Body` to remove its padding when content should reach
the edges — for example a full-width image, table, or map.

## Accessibility

**Role and modality**: `Dialog.Content` renders with `role="dialog"`, or
`role="alertdialog"` when `type="alertdialog"`. Focus is trapped within the
dialog while it is open.

**Naming**: the accessible name and description are wired automatically from the
composed parts:

* `Dialog.Title` is wired to `aria-labelledby`.
* `Dialog.Body` is wired to `aria-describedby`.

**Responsive parity**: on small screens the dialog is presented as a bottom
sheet built on the `BottomSheet`/`Drawer` primitives, but the public parts and
accessibility wiring match the desktop modal.

### Testing tip

Query by the accessible name derived from `Dialog.Title`:

```tsx
screen.getByRole("dialog", { name: /Dialog title/i });
```

## Migrating to Dialog

`Dialog` is the successor to both `Modal` (in its prop-driven and composable
`Modal.Provider` forms) and `ConfirmationModal`. This guide maps each old API
onto the composable Dialog parts.

The same target shape covers every source: a `Dialog` root that owns state and
behavior, a `Dialog.Content` surface, and the layout parts (`Header`, `Title`,
`Close`, `Body`, `Footer`, `Actions`) inside it.

```tsx
<Dialog open={open} onRequestClose={() => setOpen(false)}>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Title</Dialog.Title>
      <Dialog.Close />
    </Dialog.Header>
    <Dialog.Body>…</Dialog.Body>
    <Dialog.Footer>
      <Dialog.Actions primary={…} secondary={…} />
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>
```

### Behavior differences to know first

A few defaults changed, so a mechanical find-and-replace won't be correct on its
own:

* **Sticky is inverted.** `Modal.Header`/`Modal.Actions` were `inline` by
  default and opted into `variant="sticky"`. In Dialog the header and footer are
  **pinned by default**, and you opt *out* by wrapping the parts that should
  scroll in `Dialog.ScrollArea`. Most `variant="sticky"` usages become the plain
  default; the rare non-sticky header/footer is what now needs extra markup.
* **`dismissible={false}` becomes `type="alertdialog"`.** Blocking
  outside/backdrop dismissal is no longer a boolean — it's the alert dialog
  type, which also drops the default close button. Escape still closes.
* **Naming is automatic.** `ariaLabel` / `modalLabelledBy` are gone. A
  `Dialog.Title` is auto-wired to `aria-labelledby` and `Dialog.Body` to
  `aria-describedby`. If you have no visible title, provide one (visually hidden
  if needed) rather than an `ariaLabel` prop.
* **Sizes shifted.** See the size table at the end.

***

## Prop-driven Modal → Dialog

The legacy `<Modal>` is controlled by `open` and renders its title and actions
from props. Move the title into `Dialog.Header`/`Dialog.Title`, the children
into `Dialog.Body`, and the `*Action` props into `Dialog.Actions`. Keep
controlling `open`/`onRequestClose` exactly as before — a trigger is optional.

```tsx
// Before
<Modal
  open={open}
  onRequestClose={() => setOpen(false)}
  title="Edit client"
  size="large"
  primaryAction={{ label: "Save", onClick: handleSave }}
  secondaryAction={{ label: "Cancel", onClick: () => setOpen(false) }}
>
  <Content>{/* form */}</Content>
</Modal>
```

```tsx
// After
<Dialog open={open} onRequestClose={() => setOpen(false)} size="large">
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Edit client</Dialog.Title>
      <Dialog.Close />
    </Dialog.Header>
    <Dialog.Body>{/* form */}</Dialog.Body>
    <Dialog.Footer>
      <Dialog.Actions
        primary={{ label: "Save", onClick: handleSave }}
        secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
      />
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>
```

| Modal prop            | Dialog equivalent                                            |
| --------------------- | ------------------------------------------------------------ |
| `open`                | `open` (unchanged)                                           |
| `onRequestClose`      | `onRequestClose` (now also receives Base UI event details)   |
| `title`               | `<Dialog.Title>` inside `<Dialog.Header>`                    |
| `children`            | `<Dialog.Body>`                                              |
| `primaryAction`       | `<Dialog.Actions primary={…} />`                             |
| `secondaryAction`     | `<Dialog.Actions secondary={…} />`                           |
| `tertiaryAction`      | `<Dialog.Actions tertiary={…} />` (rendered left)            |
| `size`                | `size` (remap values — see table)                            |
| `dismissible={false}` | `type="alertdialog"` (also removes the default close button) |
| `ariaLabel`           | Use a `Dialog.Title`; naming is auto-wired                   |
| `version`             | Removed                                                      |

The default close button lives in the header as `<Dialog.Close />`, so add one
there instead of relying on the modal's built-in dismiss. If you were opening
the modal from a button, you can keep that button external (controlled `open`)
or wrap it in `Dialog.Trigger` and drop the manual `setOpen(true)`.

***

## Composable Modal (`Modal.Provider`) → Dialog

The Provider API already composes parts, so the migration is close to a rename —
with the sticky inversion being the one real change.

```tsx
// Before
<Modal.Provider open={open} onRequestClose={() => setOpen(false)}>
  <Modal.Activator>
    <Button label="Open" onClick={() => setOpen(true)} />
  </Modal.Activator>
  <Modal.Content>
    <Modal.Header title="Billing settings" variant="sticky" />
    <Content>{/* body */}</Content>
    <Modal.Actions
      variant="sticky"
      primary={{ label: "Save", onClick: handleSave }}
      secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
    />
  </Modal.Content>
</Modal.Provider>
```

```tsx
// After
<Dialog open={open} onRequestClose={() => setOpen(false)}>
  <Dialog.Trigger>
    <Button label="Open" onClick={() => setOpen(true)} />
  </Dialog.Trigger>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Billing settings</Dialog.Title>
      <Dialog.Close />
    </Dialog.Header>
    <Dialog.Body>{/* body */}</Dialog.Body>
    <Dialog.Footer>
      <Dialog.Actions
        primary={{ label: "Save", onClick: handleSave }}
        secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
      />
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>
```

| `Modal.Provider` part                 | Dialog equivalent                                   |
| ------------------------------------- | --------------------------------------------------- |
| `Modal.Provider`                      | `Dialog` (`open`, `onRequestClose`)                 |
| `Modal.Activator`                     | `Dialog.Trigger`                                    |
| `Modal.Content` (outer)               | `Dialog.Content`                                    |
| `Modal.Header title="…"`              | `Dialog.Header` + `Dialog.Title` (+ `Dialog.Close`) |
| `Modal.Header` with children          | `Dialog.Header` with children                       |
| inner `Content` / body children       | `Dialog.Body`                                       |
| `Modal.Actions`                       | `Dialog.Footer` + `Dialog.Actions`                  |
| `variant="sticky"` (header/actions)   | Default — flat parts pin automatically              |
| `variant="inline"` (scroll with body) | Wrap the part in `Dialog.ScrollArea`                |
| `dismissible={false}`                 | `type="alertdialog"`                                |
| `ariaLabel` / `modalLabelledBy`       | `Dialog.Title` auto-wires `aria-labelledby`         |

`Modal.Activator` also controlled focus return. That's its own migration — see
[Focus return](#focus-return-modalactivator--finalfocus) below.

***

## Focus return (`Modal.Activator`) → `finalFocus`

`Modal.Activator` wrapped the element that focus should return to after close.
Under the hood floating-ui also gave you a **fallback for free**: it anchored a
hidden `<span>` next to the activator, so if the element that opened the modal
unmounted while it was open — the classic "Create…" action whose trigger is
replaced by a card on save — focus still landed at a sensible nearby spot
instead of falling to `document.body`.

Base UI's dialog dropped that anchored-span mechanism, so Dialog reintroduces
the fallback explicitly through the root's `finalFocus`. There are two cases:

**The trigger stays mounted** (most dialogs). Point `finalFocus` at a ref and
you're done — this covers the plain `Modal.Activator` redirect:

```tsx
// Before
<Modal.Provider open={open} onRequestClose={() => setOpen(false)}>
  <Modal.Content>…</Modal.Content>
  <Modal.Activator>
    <InputText
      placeholder="Focus returns here"
      value={value}
      onChange={setValue}
    />
  </Modal.Activator>
</Modal.Provider>
```

```tsx
// After
const returnRef = useRef<HTMLInputElement>(null);

<Dialog
  open={open}
  onRequestClose={() => setOpen(false)}
  finalFocus={returnRef}
>
  <Dialog.Content>…</Dialog.Content>
</Dialog>;

<InputText
  ref={returnRef}
  placeholder="Focus returns here"
  value={value}
  onChange={setValue}
/>;
```

**The trigger can unmount** (the fallback floating-ui used to handle). Build
`finalFocus` with `getDialogReturnFocus({ trigger, boundary })`. The `boundary`
ref is the equivalent of the old anchored span: if the trigger is gone on close,
focus moves to the first focusable element inside `boundary` instead:

```tsx
const triggerRef = useRef<HTMLButtonElement>(null);
const boundaryRef = useRef<HTMLDivElement>(null);

<div ref={boundaryRef}>
  {created ? <Card>…</Card> : <Trigger ref={triggerRef} />}
</div>;

<Dialog
  handle={handle}
  finalFocus={getDialogReturnFocus({
    trigger: triggerRef,
    boundary: boundaryRef,
  })}
>
  …
</Dialog>;
```

Resolution order on close: the `trigger` if still connected, otherwise the first
focusable inside `boundary`, otherwise `null` (Base UI's default). Keep
`boundary` scoped tightly around the region that swaps.

| Before (`Modal.Activator`)                  | After (`finalFocus`)                                        |
| ------------------------------------------- | ----------------------------------------------------------- |
| Wrap the return target in `Modal.Activator` | `finalFocus={ref}` pointing at the return target            |
| Floating-ui's automatic unmount fallback    | `finalFocus={getDialogReturnFocus({ trigger, boundary })}`  |
| (no equivalent — was implicit)              | `boundary` names where focus lands when the trigger is gone |

See the focus-return section of the component notes for the full behavior.

***

## ConfirmationModal → Dialog

A confirmation is an alert dialog: `type="alertdialog"` blocks outside dismissal
and omits the close button, so the user must choose confirm or cancel. Map
`confirmLabel`/`onConfirm` to the primary action and `cancelLabel`/`onCancel` to
the secondary.

```tsx
// Before
<ConfirmationModal
  open={open}
  title="Delete job #2121"
  message="Deleting this job will remove all associated visits and invoices."
  confirmLabel="Delete Job"
  cancelLabel="Cancel"
  variation="destructive"
  onConfirm={handleDelete}
  onCancel={() => setOpen(false)}
/>
```

```tsx
// After
<Dialog
  type="alertdialog"
  size="small"
  open={open}
  onRequestClose={() => setOpen(false)}
>
  <Dialog.Content>
    <Dialog.Header>
      <Dialog.Title>Delete job #2121</Dialog.Title>
    </Dialog.Header>
    <Dialog.Body style={{ paddingTop: "var(--space-base)" }}>
      <Text>
        Deleting this job will remove all associated visits and invoices.
      </Text>
    </Dialog.Body>
    <Dialog.Footer>
      <Dialog.Actions
        primary={{
          label: "Delete Job",
          onClick: handleDelete,
          variation: "destructive",
        }}
        secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
      />
    </Dialog.Footer>
  </Dialog.Content>
</Dialog>
```

**Match the body spacing.** The old `ConfirmationModal` rendered its message
with `16px` (`--space-base`) of space below the title. `Dialog.Body` doesn't add
that gap by default, so add `16px` of top padding to the body to keep the same
spacing:

```tsx
<Dialog.Body style={{ paddingTop: "var(--space-base)" }}>…</Dialog.Body>
```

| ConfirmationModal prop             | Dialog equivalent                                |
| ---------------------------------- | ------------------------------------------------ |
| `open`                             | `open`                                           |
| `onCancel` / dismiss               | `onRequestClose` (+ the secondary/cancel action) |
| `title`                            | `<Dialog.Title>`                                 |
| `message`                          | `<Dialog.Body><Text>…</Text></Dialog.Body>`      |
| `children` (in place of `message`) | `<Dialog.Body>`                                  |
| `confirmLabel` + `onConfirm`       | `Dialog.Actions primary={{ label, onClick }}`    |
| `cancelLabel` + `onCancel`         | `Dialog.Actions secondary={{ label, onClick }}`  |
| `variation="destructive"`          | `primary={{ …, variation: "destructive" }}`      |
| `variation="work"`                 | Default primary button                           |
| `size="small" \| "large"`          | `size="small"` / `size="base"`                   |
| (implicit alert behavior)          | `type="alertdialog"`                             |

### The imperative `ref.show()` pattern

`ConfirmationModal` exposed a `confirmationModalRef` with a `show({ … })` method
so one modal could be reused across a list of items. Dialog replaces that with a
detached trigger connected through a handle, passing the per-item data as
`payload`:

```tsx
const handle = Dialog.createHandle();

// One Dialog, rendered once
<Dialog handle={handle} type="alertdialog" size="small">
  {({ payload }) => (
    <Dialog.Content>
      <Dialog.Header>
        <Dialog.Title>Remove {payload?.name}</Dialog.Title>
      </Dialog.Header>
      <Dialog.Body>
        <Text>{payload?.name} will be removed from your client list.</Text>
      </Dialog.Body>
      <Dialog.Footer>
        <Dialog.Actions
          primary={{
            label: "Remove Client",
            variation: "destructive",
            onClick: () => payload?.onConfirm(),
          }}
          secondary={{ label: "Cancel" }}
        />
      </Dialog.Footer>
    </Dialog.Content>
  )}
</Dialog>;

// A trigger per row, each passing its own payload
{
  clients.map(client => (
    <Dialog.Trigger key={client.id} handle={handle} payload={client}>
      <Button label="Remove" />
    </Dialog.Trigger>
  ));
}
```

If you'd rather not use a handle, hold the active item in state and drive a
single controlled `Dialog` — open it when a row's button is clicked and read the
stored item in the body.

***

## Size mapping

Dialog widths are max widths; the dialog narrows to fit smaller viewports.

| Source                           | Old width | Use in Dialog           |
| -------------------------------- | --------- | ----------------------- |
| Modal default                    | 600px     | `size="base"` (720px)   |
| Modal `size="small"`             | 400px     | `size="small"` (440px)  |
| Modal `size="large"`             | 940px     | `size="large"` (1032px) |
| Modal `size="fullScreen"`        | 100dvw    | `size="fullScreen"`     |
| ConfirmationModal `size="small"` | —         | `size="small"`          |
| ConfirmationModal `size="large"` | —         | `size="base"`           |

Widths aren't pixel-identical to the old components — pick the closest size and
verify the result rather than expecting an exact match.


## Props

### Web

#### Dialog

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode | ((state: { payload: Payload; }) => ReactNode)` | No | — | The content of the dialog. This can be a regular React node or a render function that receives the payload of the act... |
| `defaultTriggerId` | `string` | No | — | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `defaultOpen` prop to cr... |
| `finalFocus` | `boolean | RefObject<HTMLElement> | ((closeType: InteractionType) => boolean | void | HTMLElement)` | No | — | @see {@link https://base-ui.com/react/components/dialog#DialogPopup-finalFocus} |
| `handle` | `DialogHandle<Payload>` | No | — | A handle to associate the dialog with a trigger. If specified, allows external triggers to control the dialog's open ... |
| `onOpenChange` | `(open: boolean, eventDetails: DialogRootChangeEventDetails) => void` | No | — | Event handler called when the dialog is opened or closed. |
| `onOpenChangeComplete` | `(open: boolean) => void` | No | — | Event handler called after any animations complete when the dialog is opened or closed. |
| `onRequestClose` | `(eventDetails: DialogRootChangeEventDetails) => void` | No | — | Called when the user asks to close (escape, backdrop, dismiss button). Receives BaseUI's change-event details (reason... |
| `open` | `boolean` | No | — | Whether the dialog is currently open. |
| `size` | `DialogSize` | No | `base` | Width of the dialog. On mobile when this is set to "fullScreen" the bottom sheet will be full screen (100% height). |
| `triggerId` | `string` | No | — | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `open` prop to create a ... |
| `type` | `DialogType` | No | `dialog` | Type of the dialog. An `alertdialog` requires the user to choose an action before it can be closed (no outside/backdr... |

#### Dialog.Actions

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `align` | `DialogActionsAlign` | No | `right` | Layout for the right-hand action group. |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `primary` | `{ onClick?: never; external?: never; readonly name?: string; submit: never; readonly type?: ButtonType; readonly value?: string; readonly disabled?: boolean; readonly loading?: boolean; ... 17 more ...; readonly children?: never; } | ... 35 more ... | (({ ...; } | ... 35 more ... | ReactElement<...>) & ({ ...; } | ....` | No | — | Primary action (right). |
| `secondary` | `{ onClick?: never; external?: never; readonly name?: string; submit: never; readonly type?: ButtonType; readonly value?: string; readonly disabled?: boolean; readonly loading?: boolean; ... 17 more ...; readonly children?: never; } | ... 35 more ... | (({ ...; } | ... 35 more ... | ReactElement<...>) & ({ ...; } | ....` | No | — | Secondary action (right, subtle). |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
| `tertiary` | `{ onClick?: never; external?: never; readonly name?: string; submit: never; readonly type?: ButtonType; readonly value?: string; readonly disabled?: boolean; readonly loading?: boolean; ... 17 more ...; readonly children?: never; } | ... 35 more ... | (({ ...; } | ... 35 more ... | ReactElement<...>) & ({ ...; } | ....` | No | — | Tertiary/destructive action (left). |

#### Dialog.Body

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | Yes | — | Body content; describes the dialog (wired to `aria-describedby`). |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `fullBleed` | `boolean` | No | `false` | Removes body padding |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.Close

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | No | — | Optional content for the dismiss control. An Atlantis `<Button>` is adapted automatically; any other valid element is... |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `nativeButton` | `boolean` | No | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if ... |
| `render` | `DialogCloseRender` | No | — | Renders a custom dismiss element in place of the default close button. Pass an element or a function that receives th... |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.Content

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `render` | `ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogPopupState>` | No | — | @see {@link https://base-ui.com/react/components/dialog#DialogPopup-render} |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.Footer

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | Yes | — | Footer content. Commonly a `Dialog.Actions`, but the footer is a generic region — it can hold anything (a note, a che... |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.Header

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | Yes | — | Header content — commonly a `Dialog.Title` and a `Dialog.Close`. The header only owns layout; it does not impose typo... |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.ScrollArea

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | Yes | — | Content that scrolls together — typically a `Dialog.Body` and a non-pinned `Dialog.Header`/`Dialog.Footer`. |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.Title

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | Yes | — | Title text. Rendered as a `Heading` and auto-wired to the dialog's `aria-labelledby`. |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |

#### Dialog.Trigger

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `children` | `ReactNode` | No | — | Trigger content; an Atlantis `<Button>` or raw text. Optional content forwarded to the rendered element. |
| `className` | `string` | No | — | Class name applied to the part's root element. |
| `handle` | `DialogHandle<Payload>` | No | — | Links a detached trigger to a `Dialog` with the matching `handle`, when the trigger can't be nested inside the `Dialog`. |
| `id` | `string` | No | — | ID of the trigger. In addition to being forwarded to the rendered element, it is also used to specify the active trig... |
| `nativeButton` | `boolean` | No | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if ... |
| `payload` | `Payload` | No | — | Data passed to the dialog when this trigger opens it (used with `handle`). |
| `render` | `DialogTriggerRender` | No | — | Renders a custom trigger element in place of the default `<Button>`. |
| `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
