@jobber/components 8.24.1 → 8.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,953 @@
1
+ # Dialog
2
+
3
+ ## Summary
4
+
5
+ The dialog displays content in a layer above the page, focusing the user on a
6
+ single task or decision. It is activated by an interactive element such as a
7
+ button or icon button. On large screens it appears as a centered modal; on small
8
+ screens it appears as a sheet anchored to the bottom of the screen.
9
+
10
+ ## Anatomy
11
+
12
+ | Part | Description |
13
+ | ---------- | ------------------------------------------------------------------- |
14
+ | Trigger | An interactive element that opens the dialog |
15
+ | Content | The floating panel (modal) or sheet that contains the dialog |
16
+ | Header | Layout slot at the top, commonly holding the title and close button |
17
+ | Title | Primary heading of the dialog, wired to the accessible name |
18
+ | Close | The dismiss control that closes the dialog |
19
+ | Body | The main content region, and the default scroll area |
20
+ | Footer | Layout slot at the bottom, commonly holding the actions |
21
+ | Actions | Optional: the primary, secondary, and tertiary action button layout |
22
+ | ScrollArea | Optional: advanced override for which region scrolls versus pins |
23
+
24
+ ## Behavior
25
+
26
+ #### Opening and closing the dialog
27
+
28
+ The dialog opens when:
29
+
30
+ * The trigger is clicked or tapped
31
+ * The trigger is activated by keyboard (e.g. via the Enter or Space keys)
32
+
33
+ The dialog closes when:
34
+
35
+ * The close button is selected
36
+ * An action that resolves the task is selected
37
+ * A click or tap occurs outside the dialog (on the backdrop)
38
+ * The ESC key is pressed
39
+
40
+ An `alertdialog` is the exception: it ignores outside and backdrop dismissal, so
41
+ the user must choose an action to resolve it. ESC still closes it.
42
+
43
+ #### Width
44
+
45
+ Dialog width defaults to `base` (720px). It can be set to `small` (440px),
46
+ `large` (1032px), or `fullScreen`. These are max widths — the dialog narrows to
47
+ fit smaller viewports. `fullScreen` fills the viewport on large screens (squared
48
+ corners) and becomes a full-height sheet on small screens. See [Size](#size) for
49
+ when to use each.
50
+
51
+ #### Scrolling
52
+
53
+ The `Dialog.Body` is the default scroll region, while the header and footer stay
54
+ pinned as the content scrolls. Which region scrolls is decided by composition,
55
+ not a prop — wrap parts in a `Dialog.ScrollArea` when a header or footer should
56
+ scroll with the content instead of pinning.
57
+
58
+ #### Focus
59
+
60
+ While the dialog is open, focus is trapped inside it. When it closes, focus
61
+ returns to the trigger. Focus can be directed elsewhere when the trigger is
62
+ removed while the dialog is open (for example, a "Create…" action whose trigger
63
+ is replaced by a card on save).
64
+
65
+ #### Small screens
66
+
67
+ When a user is on a small screen such as a mobile device, the dialog opens as a
68
+ sheet anchored to the bottom of the screen. The parts and accessibility wiring
69
+ are identical to the desktop modal.
70
+
71
+ #### Native mobile
72
+
73
+ Atlantis does not provide a native `Dialog` equivalent in
74
+ `@jobber/components-native`. For a comparable mobile-native experience, use
75
+ [`ContentOverlay`](/components/ContentOverlay) from `@jobber/components-native`
76
+ to present content or actions in a sheet.
77
+
78
+ ## Variants
79
+
80
+ #### Type
81
+
82
+ **1. Dialog:** The default. Used for most tasks and content. Can be dismissed by
83
+ clicking outside, pressing ESC, or the close button.
84
+
85
+ **2. Alert dialog:** Used to interrupt the user and require a decision, such as
86
+ confirming a destructive action. It is always modal, cannot be dismissed by
87
+ clicking outside, and has no close button — the user must choose an action.
88
+
89
+ #### Size
90
+
91
+ | Size | Max width | Use for |
92
+ | ----------- | --------- | ------------------------------------------------------------------------- |
93
+ | Small | 440px | Short confirmations or simple choices |
94
+ | Base | 720px | The default, suitable for most content |
95
+ | Large | 1032px | Denser content or forms |
96
+ | Full screen | Viewport | Fills the viewport on large screens; a full-height sheet on small screens |
97
+
98
+ Widths are max widths — the dialog narrows to fit smaller viewports.
99
+
100
+ #### Actions
101
+
102
+ `Dialog.Actions` arranges the action buttons: a tertiary/destructive action on
103
+ the left, and primary and secondary actions grouped on the right. The footer is
104
+ a generic region, so it can also hold a note, a checkbox, or a custom layout
105
+ instead.
106
+
107
+ #### Custom content
108
+
109
+ * The body supports flexible content layouts
110
+ * `fullBleed` removes body padding when content should reach the edges
111
+ * Header and footer are layout-only slots; they own arrangement, not typography
112
+ or actions
113
+
114
+ ## Content Guidelines
115
+
116
+ These build on the general [Voice & tone](../voice-and-tone/voice-and-tone.md) and
117
+ [Formatting](../formatting/formatting.md) guidelines. A dialog interrupts the user, so
118
+ every word should earn its place: name the task, state the outcome, and give a
119
+ clear way out.
120
+
121
+ ## Accessibility
122
+
123
+ Users need to be able to open the dialog, operate its content, and close it with
124
+ assistive technology.
125
+
126
+ #### Semantics
127
+
128
+ * The surface `role` is `dialog`, or `alertdialog` for the alert type
129
+ * `Dialog.Title` is auto-wired to `aria-labelledby`
130
+ * `Dialog.Body` is auto-wired to `aria-describedby`
131
+
132
+ #### Focus
133
+
134
+ * Focus is trapped within the dialog while it is open
135
+ * On close, focus returns to the trigger
136
+ * When the trigger is removed while the dialog is open, focus can be directed to
137
+ the closest sensible element instead
138
+
139
+ #### Keyboard navigation
140
+
141
+ | Key | Behavior |
142
+ | -------------- | ---------------------------------------------------- |
143
+ | Tab | Moves focus between focusable elements in the dialog |
144
+ | Enter or Space | Activates the focused control |
145
+ | Esc | Closes the dialog |
146
+
147
+ ## Related components
148
+
149
+ * To trigger a single action rather than presenting a task or decision, use a
150
+ [Button](../Button/Button.md) or [IconButton](/components/IconButton)
151
+ * For a comparable overlay experience in native mobile, use
152
+ [BottomSheet](/components/BottomSheet) from `@jobber/components-native`
153
+ * To present a list of options or actions, use a [Menu](../Menu/Menu.md)
154
+
155
+
156
+ ## Component customization
157
+
158
+ ### Composable API
159
+
160
+ `Dialog` is composed from its building-block subcomponents rather than driven by
161
+ a single set of props: `Dialog.Trigger`, `Dialog.Content`, `Dialog.Header`,
162
+ `Dialog.Title`, `Dialog.Close`, `Dialog.Body`, `Dialog.Footer`,
163
+ `Dialog.Actions`, and `Dialog.ScrollArea`. This gives you control over the
164
+ dialog's layout while keeping the accessibility wiring automatic.
165
+
166
+ Here is a basic example:
167
+
168
+ ```tsx
169
+ <Dialog>
170
+ <Dialog.Trigger>
171
+ <Button label="Open" />
172
+ </Dialog.Trigger>
173
+ <Dialog.Content>
174
+ <Dialog.Header>
175
+ <Dialog.Title>Dialog title</Dialog.Title>
176
+ <Dialog.Close />
177
+ </Dialog.Header>
178
+ <Dialog.Body>
179
+ <Text>Body content goes here.</Text>
180
+ </Dialog.Body>
181
+ <Dialog.Footer>
182
+ <Dialog.Actions
183
+ primary={{ label: "Save", onClick: handleSave }}
184
+ secondary={{ label: "Cancel", onClick: handleCancel }}
185
+ />
186
+ </Dialog.Footer>
187
+ </Dialog.Content>
188
+ </Dialog>
189
+ ```
190
+
191
+ `Dialog.Header` and `Dialog.Footer` are layout-only slots — they own
192
+ arrangement, not typography or actions. `Dialog.Actions` is the action-button
193
+ layout (tertiary on the left; primary/secondary grouped on the right) and is
194
+ dropped inside a `Dialog.Footer`.
195
+
196
+ ### Controlled open state
197
+
198
+ By default the dialog manages its own open state — the trigger opens it and the
199
+ close/actions dismiss it, with no state to wire up. To drive it yourself (for
200
+ example, to open it from elsewhere or to gate closing on a save), pass `open`
201
+ and handle `onRequestClose`. `onRequestClose` fires whenever the user asks to
202
+ close — Escape, a backdrop click, or the dismiss button — so a controlled dialog
203
+ should update its state there. The trigger's own control still opens the dialog
204
+ via its `onClick`.
205
+
206
+ ```tsx
207
+ function ControlledDialog() {
208
+ const [open, setOpen] = useState(false);
209
+
210
+ return (
211
+ <Dialog open={open} onRequestClose={() => setOpen(false)}>
212
+ <Dialog.Trigger>
213
+ <Button label="Open" onClick={() => setOpen(true)} />
214
+ </Dialog.Trigger>
215
+ <Dialog.Content>
216
+ <Dialog.Header>
217
+ <Dialog.Title>Edit details</Dialog.Title>
218
+ <Dialog.Close />
219
+ </Dialog.Header>
220
+ <Dialog.Body>
221
+ <Text>Body content goes here.</Text>
222
+ </Dialog.Body>
223
+ <Dialog.Footer>
224
+ <Dialog.Actions
225
+ primary={{ label: "Save", onClick: () => setOpen(false) }}
226
+ secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
227
+ />
228
+ </Dialog.Footer>
229
+ </Dialog.Content>
230
+ </Dialog>
231
+ );
232
+ }
233
+ ```
234
+
235
+ Because the state is yours, you can keep the dialog open when a save fails —
236
+ only call `setOpen(false)` once the action succeeds. When you don't need that
237
+ control, omit `open`/`onRequestClose` and let the dialog manage itself.
238
+
239
+ ### Trigger and close customization
240
+
241
+ `Dialog.Trigger` and `Dialog.Close` accept Atlantis-style content and can render
242
+ a default control, composed Atlantis `Button` content, or a custom element via
243
+ `render`. An Atlantis `<Button>`/`<ButtonDismiss>` is adapted automatically; any
244
+ other valid element is used as-is. When `render` is a function, it receives the
245
+ Base UI props so the element stays wired to open/close the dialog.
246
+
247
+ ```tsx
248
+ {/* default primary Button trigger / default ButtonDismiss close */}
249
+ <Dialog.Trigger>Open</Dialog.Trigger>
250
+ <Dialog.Close />
251
+
252
+ {/* custom element */}
253
+ <Dialog.Trigger render={<Button type="tertiary" label="Open" />} />
254
+ <Dialog.Close render={<Button type="tertiary" label="Done" />} />
255
+
256
+ {/* render function */}
257
+ <Dialog.Close render={closeProps => <MyClose {...closeProps} />} />
258
+ ```
259
+
260
+ ### Detached triggers
261
+
262
+ When the trigger can't be nested inside the `Dialog` — for example an action
263
+ inside a `Combobox`, `Autocomplete`, or `Menu` overlay — connect it through a
264
+ `handle` from `Dialog.createHandle()` instead of nesting. The trigger can pass
265
+ `payload` data that is available to `Dialog`'s render-function `children`.
266
+
267
+ ```tsx
268
+ const handle = Dialog.createHandle();
269
+
270
+ <Dialog handle={handle}>
271
+ {({ payload }) => <Dialog.Content>{/* read payload here */}</Dialog.Content>}
272
+ </Dialog>;
273
+
274
+ <Dialog.Trigger handle={handle} payload={selectedClient}>
275
+ Create client
276
+ </Dialog.Trigger>;
277
+ ```
278
+
279
+ ### Scroll and sticky model
280
+
281
+ Composition — not a `sticky` prop — decides what scrolls. The scroll region is a
282
+ real element and owns the scrollbar:
283
+
284
+ * Content **inside** the scroll region scrolls (the scrollbar lives here).
285
+ * Content **outside** the scroll region (a direct child of `Dialog.Content`)
286
+ stays pinned.
287
+
288
+ `Dialog.Body` is the default scroll region, so a flat `Header`/`Body`/`Footer`
289
+ pins the header and footer and scrolls the body — the common case needs nothing
290
+ extra.
291
+
292
+ ```tsx
293
+ <Dialog.Content>
294
+ <Dialog.Header /> {/* pinned */}
295
+ <Dialog.Body>…</Dialog.Body> {/* scrolls */}
296
+ <Dialog.Footer /> {/* pinned */}
297
+ </Dialog.Content>
298
+ ```
299
+
300
+ To make a header or footer scroll with the content instead of pinning, wrap the
301
+ parts that should scroll together in `Dialog.ScrollArea`. Anything inside it
302
+ scrolls; anything left as a sibling in `Dialog.Content` stays pinned.
303
+
304
+ ```tsx
305
+ {
306
+ /* sticky Header, scrolling Footer */
307
+ }
308
+ <Dialog.Content>
309
+ <Dialog.Header /> {/* pinned */}
310
+ <Dialog.ScrollArea>
311
+ <Dialog.Body>…</Dialog.Body>
312
+ <Dialog.Footer /> {/* inside → scrolls */}
313
+ </Dialog.ScrollArea>
314
+ </Dialog.Content>;
315
+ ```
316
+
317
+ The four sticky combinations map to composition:
318
+
319
+ | Sticky combination | Composition |
320
+ | ------------------------------- | ---------------------------------------------------- |
321
+ | Both sticky | `Header`, `Body`, `Footer` flat in `Content` (basic) |
322
+ | Sticky header, scrolling footer | `Header`, then `ScrollArea{ Body, Footer }` |
323
+ | Scrolling header, sticky footer | `ScrollArea{ Header, Body }`, then `Footer` |
324
+ | Nothing sticky | `ScrollArea{ Header, Body, Footer }` |
325
+
326
+ ### Rendering the surface as a form
327
+
328
+ `Dialog.Content` accepts Base UI's `render` escape hatch. Pass
329
+ `render={<form onSubmit={…} />}` to make the surface itself a `<form>`, so a
330
+ submit button in `Dialog.Footer` submits it while the header and footer stay
331
+ pinned. For two or more independent forms in one dialog, give each
332
+ `<form id="…">` its own id in the body and point each submit control at it with
333
+ the native `form` attribute instead.
334
+
335
+ ```tsx
336
+ <Dialog.Content render={<form onSubmit={handleSubmit} />}>
337
+ <Dialog.Header>…</Dialog.Header>
338
+ <Dialog.Body>{/* form fields */}</Dialog.Body>
339
+ <Dialog.Footer>
340
+ <Dialog.Actions primary={{ label: "Save", type: "submit" }} />
341
+ </Dialog.Footer>
342
+ </Dialog.Content>
343
+ ```
344
+
345
+ ### Focus return when the trigger unmounts
346
+
347
+ Base UI returns focus on close to the trigger, then the element focused before
348
+ open — each only if it is still connected to the DOM. It never walks to a
349
+ neighbor. A common Jobber flow breaks both at once: a "Create…" action opens the
350
+ dialog, and on save the trigger is replaced by a card. By the time the dialog
351
+ closes, the trigger is gone and focus falls to `document.body`.
352
+
353
+ For that case, `finalFocus` accepts a function. Build it with
354
+ `getDialogReturnFocus({ trigger, boundary })`:
355
+
356
+ ```tsx
357
+ const triggerRef = useRef<HTMLButtonElement>(null);
358
+ const boundaryRef = useRef<HTMLDivElement>(null);
359
+
360
+ <div ref={boundaryRef}>
361
+ {created ? <Card>…</Card> : <Trigger ref={triggerRef} />}
362
+ </div>;
363
+
364
+ <Dialog
365
+ handle={handle}
366
+ finalFocus={getDialogReturnFocus({
367
+ trigger: triggerRef,
368
+ boundary: boundaryRef,
369
+ })}
370
+ >
371
+
372
+ </Dialog>;
373
+ ```
374
+
375
+ Resolution order on close:
376
+
377
+ 1. `trigger`, if it is still connected (the normal case);
378
+ 2. otherwise the first focusable element inside `boundary` (typically the card
379
+ that replaced the trigger);
380
+ 3. otherwise `null`, letting Base UI fall back to its default behavior.
381
+
382
+ Keep `boundary` scoped tightly around the region that swaps. When the trigger
383
+ never unmounts, a plain `finalFocus={triggerRef}` is enough and this helper is
384
+ unnecessary.
385
+
386
+ ### Alert dialog
387
+
388
+ `type="alertdialog"` is always modal, ignores outside/backdrop dismissal, and
389
+ omits the default `Dialog.Close` button — the user must choose an action. Escape
390
+ still closes it. Always include a cancel action so there is a genuine way out.
391
+
392
+ ```tsx
393
+ <Dialog type="alertdialog">
394
+ <Dialog.Trigger>Delete client</Dialog.Trigger>
395
+ <Dialog.Content>
396
+ <Dialog.Header>
397
+ <Dialog.Title>Delete client</Dialog.Title>
398
+ </Dialog.Header>
399
+ <Dialog.Body>
400
+ <Text>
401
+ Sarah Johnson and all her history will be removed. This can't be undone.
402
+ </Text>
403
+ </Dialog.Body>
404
+ <Dialog.Footer>
405
+ <Dialog.Actions
406
+ primary={{ label: "Delete Client", onClick: handleDelete }}
407
+ secondary={{ label: "Cancel", onClick: handleCancel }}
408
+ />
409
+ </Dialog.Footer>
410
+ </Dialog.Content>
411
+ </Dialog>
412
+ ```
413
+
414
+ ## Sizing
415
+
416
+ `size` accepts `"small"` (440px), `"base"` (720px, the default), `"large"`
417
+ (1032px), and `"fullScreen"`. The pixel values are max widths; the dialog
418
+ narrows to fit smaller viewports.
419
+
420
+ Use `size="fullScreen"` for complex workflows or dense layouts that need more
421
+ space without turning the task into a new route. On desktop it fills the
422
+ viewport with squared corners; on mobile the bottom sheet becomes a full-width,
423
+ full-height surface instead of the default peek.
424
+
425
+ Use `fullBleed` on `Dialog.Body` to remove its padding when content should reach
426
+ the edges — for example a full-width image, table, or map.
427
+
428
+ ## Accessibility
429
+
430
+ **Role and modality**: `Dialog.Content` renders with `role="dialog"`, or
431
+ `role="alertdialog"` when `type="alertdialog"`. Focus is trapped within the
432
+ dialog while it is open.
433
+
434
+ **Naming**: the accessible name and description are wired automatically from the
435
+ composed parts:
436
+
437
+ * `Dialog.Title` is wired to `aria-labelledby`.
438
+ * `Dialog.Body` is wired to `aria-describedby`.
439
+
440
+ **Responsive parity**: on small screens the dialog is presented as a bottom
441
+ sheet built on the `BottomSheet`/`Drawer` primitives, but the public parts and
442
+ accessibility wiring match the desktop modal.
443
+
444
+ ### Testing tip
445
+
446
+ Query by the accessible name derived from `Dialog.Title`:
447
+
448
+ ```tsx
449
+ screen.getByRole("dialog", { name: /Dialog title/i });
450
+ ```
451
+
452
+ ## Migrating to Dialog
453
+
454
+ `Dialog` is the successor to both `Modal` (in its prop-driven and composable
455
+ `Modal.Provider` forms) and `ConfirmationModal`. This guide maps each old API
456
+ onto the composable Dialog parts.
457
+
458
+ The same target shape covers every source: a `Dialog` root that owns state and
459
+ behavior, a `Dialog.Content` surface, and the layout parts (`Header`, `Title`,
460
+ `Close`, `Body`, `Footer`, `Actions`) inside it.
461
+
462
+ ```tsx
463
+ <Dialog open={open} onRequestClose={() => setOpen(false)}>
464
+ <Dialog.Content>
465
+ <Dialog.Header>
466
+ <Dialog.Title>Title</Dialog.Title>
467
+ <Dialog.Close />
468
+ </Dialog.Header>
469
+ <Dialog.Body>…</Dialog.Body>
470
+ <Dialog.Footer>
471
+ <Dialog.Actions primary={…} secondary={…} />
472
+ </Dialog.Footer>
473
+ </Dialog.Content>
474
+ </Dialog>
475
+ ```
476
+
477
+ ### Behavior differences to know first
478
+
479
+ A few defaults changed, so a mechanical find-and-replace won't be correct on its
480
+ own:
481
+
482
+ * **Sticky is inverted.** `Modal.Header`/`Modal.Actions` were `inline` by
483
+ default and opted into `variant="sticky"`. In Dialog the header and footer are
484
+ **pinned by default**, and you opt *out* by wrapping the parts that should
485
+ scroll in `Dialog.ScrollArea`. Most `variant="sticky"` usages become the plain
486
+ default; the rare non-sticky header/footer is what now needs extra markup.
487
+ * **`dismissible={false}` becomes `type="alertdialog"`.** Blocking
488
+ outside/backdrop dismissal is no longer a boolean — it's the alert dialog
489
+ type, which also drops the default close button. Escape still closes.
490
+ * **Naming is automatic.** `ariaLabel` / `modalLabelledBy` are gone. A
491
+ `Dialog.Title` is auto-wired to `aria-labelledby` and `Dialog.Body` to
492
+ `aria-describedby`. If you have no visible title, provide one (visually hidden
493
+ if needed) rather than an `ariaLabel` prop.
494
+ * **Sizes shifted.** See the size table at the end.
495
+
496
+ ***
497
+
498
+ ## Prop-driven Modal → Dialog
499
+
500
+ The legacy `<Modal>` is controlled by `open` and renders its title and actions
501
+ from props. Move the title into `Dialog.Header`/`Dialog.Title`, the children
502
+ into `Dialog.Body`, and the `*Action` props into `Dialog.Actions`. Keep
503
+ controlling `open`/`onRequestClose` exactly as before — a trigger is optional.
504
+
505
+ ```tsx
506
+ // Before
507
+ <Modal
508
+ open={open}
509
+ onRequestClose={() => setOpen(false)}
510
+ title="Edit client"
511
+ size="large"
512
+ primaryAction={{ label: "Save", onClick: handleSave }}
513
+ secondaryAction={{ label: "Cancel", onClick: () => setOpen(false) }}
514
+ >
515
+ <Content>{/* form */}</Content>
516
+ </Modal>
517
+ ```
518
+
519
+ ```tsx
520
+ // After
521
+ <Dialog open={open} onRequestClose={() => setOpen(false)} size="large">
522
+ <Dialog.Content>
523
+ <Dialog.Header>
524
+ <Dialog.Title>Edit client</Dialog.Title>
525
+ <Dialog.Close />
526
+ </Dialog.Header>
527
+ <Dialog.Body>{/* form */}</Dialog.Body>
528
+ <Dialog.Footer>
529
+ <Dialog.Actions
530
+ primary={{ label: "Save", onClick: handleSave }}
531
+ secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
532
+ />
533
+ </Dialog.Footer>
534
+ </Dialog.Content>
535
+ </Dialog>
536
+ ```
537
+
538
+ | Modal prop | Dialog equivalent |
539
+ | --------------------- | ------------------------------------------------------------ |
540
+ | `open` | `open` (unchanged) |
541
+ | `onRequestClose` | `onRequestClose` (now also receives Base UI event details) |
542
+ | `title` | `<Dialog.Title>` inside `<Dialog.Header>` |
543
+ | `children` | `<Dialog.Body>` |
544
+ | `primaryAction` | `<Dialog.Actions primary={…} />` |
545
+ | `secondaryAction` | `<Dialog.Actions secondary={…} />` |
546
+ | `tertiaryAction` | `<Dialog.Actions tertiary={…} />` (rendered left) |
547
+ | `size` | `size` (remap values — see table) |
548
+ | `dismissible={false}` | `type="alertdialog"` (also removes the default close button) |
549
+ | `ariaLabel` | Use a `Dialog.Title`; naming is auto-wired |
550
+ | `version` | Removed |
551
+
552
+ The default close button lives in the header as `<Dialog.Close />`, so add one
553
+ there instead of relying on the modal's built-in dismiss. If you were opening
554
+ the modal from a button, you can keep that button external (controlled `open`)
555
+ or wrap it in `Dialog.Trigger` and drop the manual `setOpen(true)`.
556
+
557
+ ***
558
+
559
+ ## Composable Modal (`Modal.Provider`) → Dialog
560
+
561
+ The Provider API already composes parts, so the migration is close to a rename —
562
+ with the sticky inversion being the one real change.
563
+
564
+ ```tsx
565
+ // Before
566
+ <Modal.Provider open={open} onRequestClose={() => setOpen(false)}>
567
+ <Modal.Activator>
568
+ <Button label="Open" onClick={() => setOpen(true)} />
569
+ </Modal.Activator>
570
+ <Modal.Content>
571
+ <Modal.Header title="Billing settings" variant="sticky" />
572
+ <Content>{/* body */}</Content>
573
+ <Modal.Actions
574
+ variant="sticky"
575
+ primary={{ label: "Save", onClick: handleSave }}
576
+ secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
577
+ />
578
+ </Modal.Content>
579
+ </Modal.Provider>
580
+ ```
581
+
582
+ ```tsx
583
+ // After
584
+ <Dialog open={open} onRequestClose={() => setOpen(false)}>
585
+ <Dialog.Trigger>
586
+ <Button label="Open" onClick={() => setOpen(true)} />
587
+ </Dialog.Trigger>
588
+ <Dialog.Content>
589
+ <Dialog.Header>
590
+ <Dialog.Title>Billing settings</Dialog.Title>
591
+ <Dialog.Close />
592
+ </Dialog.Header>
593
+ <Dialog.Body>{/* body */}</Dialog.Body>
594
+ <Dialog.Footer>
595
+ <Dialog.Actions
596
+ primary={{ label: "Save", onClick: handleSave }}
597
+ secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
598
+ />
599
+ </Dialog.Footer>
600
+ </Dialog.Content>
601
+ </Dialog>
602
+ ```
603
+
604
+ | `Modal.Provider` part | Dialog equivalent |
605
+ | ------------------------------------- | --------------------------------------------------- |
606
+ | `Modal.Provider` | `Dialog` (`open`, `onRequestClose`) |
607
+ | `Modal.Activator` | `Dialog.Trigger` |
608
+ | `Modal.Content` (outer) | `Dialog.Content` |
609
+ | `Modal.Header title="…"` | `Dialog.Header` + `Dialog.Title` (+ `Dialog.Close`) |
610
+ | `Modal.Header` with children | `Dialog.Header` with children |
611
+ | inner `Content` / body children | `Dialog.Body` |
612
+ | `Modal.Actions` | `Dialog.Footer` + `Dialog.Actions` |
613
+ | `variant="sticky"` (header/actions) | Default — flat parts pin automatically |
614
+ | `variant="inline"` (scroll with body) | Wrap the part in `Dialog.ScrollArea` |
615
+ | `dismissible={false}` | `type="alertdialog"` |
616
+ | `ariaLabel` / `modalLabelledBy` | `Dialog.Title` auto-wires `aria-labelledby` |
617
+
618
+ `Modal.Activator` also controlled focus return. That's its own migration — see
619
+ [Focus return](#focus-return-modalactivator--finalfocus) below.
620
+
621
+ ***
622
+
623
+ ## Focus return (`Modal.Activator`) → `finalFocus`
624
+
625
+ `Modal.Activator` wrapped the element that focus should return to after close.
626
+ Under the hood floating-ui also gave you a **fallback for free**: it anchored a
627
+ hidden `<span>` next to the activator, so if the element that opened the modal
628
+ unmounted while it was open — the classic "Create…" action whose trigger is
629
+ replaced by a card on save — focus still landed at a sensible nearby spot
630
+ instead of falling to `document.body`.
631
+
632
+ Base UI's dialog dropped that anchored-span mechanism, so Dialog reintroduces
633
+ the fallback explicitly through the root's `finalFocus`. There are two cases:
634
+
635
+ **The trigger stays mounted** (most dialogs). Point `finalFocus` at a ref and
636
+ you're done — this covers the plain `Modal.Activator` redirect:
637
+
638
+ ```tsx
639
+ // Before
640
+ <Modal.Provider open={open} onRequestClose={() => setOpen(false)}>
641
+ <Modal.Content>…</Modal.Content>
642
+ <Modal.Activator>
643
+ <InputText
644
+ placeholder="Focus returns here"
645
+ value={value}
646
+ onChange={setValue}
647
+ />
648
+ </Modal.Activator>
649
+ </Modal.Provider>
650
+ ```
651
+
652
+ ```tsx
653
+ // After
654
+ const returnRef = useRef<HTMLInputElement>(null);
655
+
656
+ <Dialog
657
+ open={open}
658
+ onRequestClose={() => setOpen(false)}
659
+ finalFocus={returnRef}
660
+ >
661
+ <Dialog.Content>…</Dialog.Content>
662
+ </Dialog>;
663
+
664
+ <InputText
665
+ ref={returnRef}
666
+ placeholder="Focus returns here"
667
+ value={value}
668
+ onChange={setValue}
669
+ />;
670
+ ```
671
+
672
+ **The trigger can unmount** (the fallback floating-ui used to handle). Build
673
+ `finalFocus` with `getDialogReturnFocus({ trigger, boundary })`. The `boundary`
674
+ ref is the equivalent of the old anchored span: if the trigger is gone on close,
675
+ focus moves to the first focusable element inside `boundary` instead:
676
+
677
+ ```tsx
678
+ const triggerRef = useRef<HTMLButtonElement>(null);
679
+ const boundaryRef = useRef<HTMLDivElement>(null);
680
+
681
+ <div ref={boundaryRef}>
682
+ {created ? <Card>…</Card> : <Trigger ref={triggerRef} />}
683
+ </div>;
684
+
685
+ <Dialog
686
+ handle={handle}
687
+ finalFocus={getDialogReturnFocus({
688
+ trigger: triggerRef,
689
+ boundary: boundaryRef,
690
+ })}
691
+ >
692
+
693
+ </Dialog>;
694
+ ```
695
+
696
+ Resolution order on close: the `trigger` if still connected, otherwise the first
697
+ focusable inside `boundary`, otherwise `null` (Base UI's default). Keep
698
+ `boundary` scoped tightly around the region that swaps.
699
+
700
+ | Before (`Modal.Activator`) | After (`finalFocus`) |
701
+ | ------------------------------------------- | ----------------------------------------------------------- |
702
+ | Wrap the return target in `Modal.Activator` | `finalFocus={ref}` pointing at the return target |
703
+ | Floating-ui's automatic unmount fallback | `finalFocus={getDialogReturnFocus({ trigger, boundary })}` |
704
+ | (no equivalent — was implicit) | `boundary` names where focus lands when the trigger is gone |
705
+
706
+ See the focus-return section of the component notes for the full behavior.
707
+
708
+ ***
709
+
710
+ ## ConfirmationModal → Dialog
711
+
712
+ A confirmation is an alert dialog: `type="alertdialog"` blocks outside dismissal
713
+ and omits the close button, so the user must choose confirm or cancel. Map
714
+ `confirmLabel`/`onConfirm` to the primary action and `cancelLabel`/`onCancel` to
715
+ the secondary.
716
+
717
+ ```tsx
718
+ // Before
719
+ <ConfirmationModal
720
+ open={open}
721
+ title="Delete job #2121"
722
+ message="Deleting this job will remove all associated visits and invoices."
723
+ confirmLabel="Delete Job"
724
+ cancelLabel="Cancel"
725
+ variation="destructive"
726
+ onConfirm={handleDelete}
727
+ onCancel={() => setOpen(false)}
728
+ />
729
+ ```
730
+
731
+ ```tsx
732
+ // After
733
+ <Dialog
734
+ type="alertdialog"
735
+ size="small"
736
+ open={open}
737
+ onRequestClose={() => setOpen(false)}
738
+ >
739
+ <Dialog.Content>
740
+ <Dialog.Header>
741
+ <Dialog.Title>Delete job #2121</Dialog.Title>
742
+ </Dialog.Header>
743
+ <Dialog.Body style={{ paddingTop: "var(--space-base)" }}>
744
+ <Text>
745
+ Deleting this job will remove all associated visits and invoices.
746
+ </Text>
747
+ </Dialog.Body>
748
+ <Dialog.Footer>
749
+ <Dialog.Actions
750
+ primary={{
751
+ label: "Delete Job",
752
+ onClick: handleDelete,
753
+ variation: "destructive",
754
+ }}
755
+ secondary={{ label: "Cancel", onClick: () => setOpen(false) }}
756
+ />
757
+ </Dialog.Footer>
758
+ </Dialog.Content>
759
+ </Dialog>
760
+ ```
761
+
762
+ **Match the body spacing.** The old `ConfirmationModal` rendered its message
763
+ with `16px` (`--space-base`) of space below the title. `Dialog.Body` doesn't add
764
+ that gap by default, so add `16px` of top padding to the body to keep the same
765
+ spacing:
766
+
767
+ ```tsx
768
+ <Dialog.Body style={{ paddingTop: "var(--space-base)" }}>…</Dialog.Body>
769
+ ```
770
+
771
+ | ConfirmationModal prop | Dialog equivalent |
772
+ | ---------------------------------- | ------------------------------------------------ |
773
+ | `open` | `open` |
774
+ | `onCancel` / dismiss | `onRequestClose` (+ the secondary/cancel action) |
775
+ | `title` | `<Dialog.Title>` |
776
+ | `message` | `<Dialog.Body><Text>…</Text></Dialog.Body>` |
777
+ | `children` (in place of `message`) | `<Dialog.Body>` |
778
+ | `confirmLabel` + `onConfirm` | `Dialog.Actions primary={{ label, onClick }}` |
779
+ | `cancelLabel` + `onCancel` | `Dialog.Actions secondary={{ label, onClick }}` |
780
+ | `variation="destructive"` | `primary={{ …, variation: "destructive" }}` |
781
+ | `variation="work"` | Default primary button |
782
+ | `size="small" \| "large"` | `size="small"` / `size="base"` |
783
+ | (implicit alert behavior) | `type="alertdialog"` |
784
+
785
+ ### The imperative `ref.show()` pattern
786
+
787
+ `ConfirmationModal` exposed a `confirmationModalRef` with a `show({ … })` method
788
+ so one modal could be reused across a list of items. Dialog replaces that with a
789
+ detached trigger connected through a handle, passing the per-item data as
790
+ `payload`:
791
+
792
+ ```tsx
793
+ const handle = Dialog.createHandle();
794
+
795
+ // One Dialog, rendered once
796
+ <Dialog handle={handle} type="alertdialog" size="small">
797
+ {({ payload }) => (
798
+ <Dialog.Content>
799
+ <Dialog.Header>
800
+ <Dialog.Title>Remove {payload?.name}</Dialog.Title>
801
+ </Dialog.Header>
802
+ <Dialog.Body>
803
+ <Text>{payload?.name} will be removed from your client list.</Text>
804
+ </Dialog.Body>
805
+ <Dialog.Footer>
806
+ <Dialog.Actions
807
+ primary={{
808
+ label: "Remove Client",
809
+ variation: "destructive",
810
+ onClick: () => payload?.onConfirm(),
811
+ }}
812
+ secondary={{ label: "Cancel" }}
813
+ />
814
+ </Dialog.Footer>
815
+ </Dialog.Content>
816
+ )}
817
+ </Dialog>;
818
+
819
+ // A trigger per row, each passing its own payload
820
+ {
821
+ clients.map(client => (
822
+ <Dialog.Trigger key={client.id} handle={handle} payload={client}>
823
+ <Button label="Remove" />
824
+ </Dialog.Trigger>
825
+ ));
826
+ }
827
+ ```
828
+
829
+ If you'd rather not use a handle, hold the active item in state and drive a
830
+ single controlled `Dialog` — open it when a row's button is clicked and read the
831
+ stored item in the body.
832
+
833
+ ***
834
+
835
+ ## Size mapping
836
+
837
+ Dialog widths are max widths; the dialog narrows to fit smaller viewports.
838
+
839
+ | Source | Old width | Use in Dialog |
840
+ | -------------------------------- | --------- | ----------------------- |
841
+ | Modal default | 600px | `size="base"` (720px) |
842
+ | Modal `size="small"` | 400px | `size="small"` (440px) |
843
+ | Modal `size="large"` | 940px | `size="large"` (1032px) |
844
+ | Modal `size="fullScreen"` | 100dvw | `size="fullScreen"` |
845
+ | ConfirmationModal `size="small"` | — | `size="small"` |
846
+ | ConfirmationModal `size="large"` | — | `size="base"` |
847
+
848
+ Widths aren't pixel-identical to the old components — pick the closest size and
849
+ verify the result rather than expecting an exact match.
850
+
851
+
852
+ ## Props
853
+
854
+ ### Web
855
+
856
+ #### Dialog
857
+
858
+ | Prop | Type | Required | Default | Description |
859
+ |------|------|----------|---------|-------------|
860
+ | `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... |
861
+ | `defaultTriggerId` | `string` | No | — | ID of the trigger that the dialog is associated with. This is useful in conjunction with the `defaultOpen` prop to cr... |
862
+ | `finalFocus` | `boolean | RefObject<HTMLElement> | ((closeType: InteractionType) => boolean | void | HTMLElement)` | No | — | @see {@link https://base-ui.com/react/components/dialog#DialogPopup-finalFocus} |
863
+ | `handle` | `DialogHandle<Payload>` | No | — | A handle to associate the dialog with a trigger. If specified, allows external triggers to control the dialog's open ... |
864
+ | `onOpenChange` | `(open: boolean, eventDetails: DialogRootChangeEventDetails) => void` | No | — | Event handler called when the dialog is opened or closed. |
865
+ | `onOpenChangeComplete` | `(open: boolean) => void` | No | — | Event handler called after any animations complete when the dialog is opened or closed. |
866
+ | `onRequestClose` | `(eventDetails: DialogRootChangeEventDetails) => void` | No | — | Called when the user asks to close (escape, backdrop, dismiss button). Receives BaseUI's change-event details (reason... |
867
+ | `open` | `boolean` | No | — | Whether the dialog is currently open. |
868
+ | `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). |
869
+ | `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 ... |
870
+ | `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... |
871
+
872
+ #### Dialog.Actions
873
+
874
+ | Prop | Type | Required | Default | Description |
875
+ |------|------|----------|---------|-------------|
876
+ | `align` | `DialogActionsAlign` | No | `right` | Layout for the right-hand action group. |
877
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
878
+ | `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). |
879
+ | `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). |
880
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
881
+ | `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). |
882
+
883
+ #### Dialog.Body
884
+
885
+ | Prop | Type | Required | Default | Description |
886
+ |------|------|----------|---------|-------------|
887
+ | `children` | `ReactNode` | Yes | — | Body content; describes the dialog (wired to `aria-describedby`). |
888
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
889
+ | `fullBleed` | `boolean` | No | `false` | Removes body padding |
890
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
891
+
892
+ #### Dialog.Close
893
+
894
+ | Prop | Type | Required | Default | Description |
895
+ |------|------|----------|---------|-------------|
896
+ | `children` | `ReactNode` | No | — | Optional content for the dismiss control. An Atlantis `<Button>` is adapted automatically; any other valid element is... |
897
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
898
+ | `nativeButton` | `boolean` | No | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if ... |
899
+ | `render` | `DialogCloseRender` | No | — | Renders a custom dismiss element in place of the default `ButtonDismiss` Pass an element or a function that receives ... |
900
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
901
+
902
+ #### Dialog.Content
903
+
904
+ | Prop | Type | Required | Default | Description |
905
+ |------|------|----------|---------|-------------|
906
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
907
+ | `render` | `ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, DialogPopupState>` | No | — | @see {@link https://base-ui.com/react/components/dialog#DialogPopup-render} |
908
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
909
+
910
+ #### Dialog.Footer
911
+
912
+ | Prop | Type | Required | Default | Description |
913
+ |------|------|----------|---------|-------------|
914
+ | `children` | `ReactNode` | Yes | — | Footer content. Commonly a `Dialog.Actions`, but the footer is a generic region — it can hold anything (a note, a che... |
915
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
916
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
917
+
918
+ #### Dialog.Header
919
+
920
+ | Prop | Type | Required | Default | Description |
921
+ |------|------|----------|---------|-------------|
922
+ | `children` | `ReactNode` | Yes | — | Header content — commonly a `Dialog.Title` and a `Dialog.Close`. The header only owns layout; it does not impose typo... |
923
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
924
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
925
+
926
+ #### Dialog.ScrollArea
927
+
928
+ | Prop | Type | Required | Default | Description |
929
+ |------|------|----------|---------|-------------|
930
+ | `children` | `ReactNode` | Yes | — | Content that scrolls together — typically a `Dialog.Body` and a non-pinned `Dialog.Header`/`Dialog.Footer`. |
931
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
932
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
933
+
934
+ #### Dialog.Title
935
+
936
+ | Prop | Type | Required | Default | Description |
937
+ |------|------|----------|---------|-------------|
938
+ | `children` | `ReactNode` | Yes | — | Title text. Rendered as a `Heading` and auto-wired to the dialog's `aria-labelledby`. |
939
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
940
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |
941
+
942
+ #### Dialog.Trigger
943
+
944
+ | Prop | Type | Required | Default | Description |
945
+ |------|------|----------|---------|-------------|
946
+ | `children` | `ReactNode` | No | — | Trigger content; an Atlantis `<Button>` or raw text. Optional content forwarded to the rendered element. |
947
+ | `className` | `string` | No | — | Class name applied to the part's root element. |
948
+ | `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`. |
949
+ | `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... |
950
+ | `nativeButton` | `boolean` | No | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if ... |
951
+ | `payload` | `Payload` | No | — | Data passed to the dialog when this trigger opens it (used with `handle`). |
952
+ | `render` | `DialogTriggerRender` | No | — | Renders a custom trigger element in place of the default `<Button>`. |
953
+ | `style` | `CSSProperties` | No | — | Inline styles applied to the part's root element. |