Dialog
A window over the page that holds focus until it closes: a form, a confirmation, details.
Installation
npx @yolkui/cli add dialogUsage
Pass the button that opens it through render: render={<Button variant="outline" />}. DialogContent draws the backdrop, the dialog and an × in the corner; DialogHeader holds the title and description, DialogFooter the actions, at the end, the secondary first and the primary last. Close a dialog with a DialogClose rendered as a Button. Give it an async onClick for a Save: the dialog waits, the button loading, and closes when the work is done (it stays open if the promise rejects or resolves to false; Cancel and the × still leave). Escape, a click on the backdrop and the × close it too, and focus goes back to the button that opened it.
- showCloseButton={false} leaves the × out. Keep it unless a button in the footer already closes the dialog: on a phone it is the obvious way out.
- On a phone the dialog sits 16px from the edges and the footer's buttons stack full width, the primary on top.
- Select, menus and tooltips inside a dialog open above it, and the first Escape closes only them.
Long content
Put long content in a body that scrolls between the header and the footer, so the title and the actions never leave the screen. The body is a plain div with overflow-y-auto (no part needed); -mx-6 px-6 puts its scrollbar at the dialog's edge, and tabIndex={0} with a name lets a keyboard scroll it. Without one the whole dialog scrolls inside, never the page behind it.
Scrolling the whole dialog
scroll="outside" on DialogContent lets a dialog much taller than the screen run past the bottom, and the whole of it scrolls over the page, as a page of its own. For long reading. A form with actions is better with the default, where the buttons stay on screen.
A part outside the dialog
With scroll="outside" the dialog no longer clips at its edge, so a part can sit outside it: a close button above the corner of a preview. Keep it inside DialogContent, so Tab still reaches it and a click on it is not a click outside.
Without the ×
When there is nothing to save and one way out, showCloseButton={false} on DialogContent leaves the × out and showCloseButton on DialogFooter adds an outline Close button (closeLabel translates it). A button of your own, as a DialogClose, works the same.
One dialog, many triggers
For an Edit button on every row, render one dialog and link the buttons to it with a handle: createDialogHandle<Member>(), passed as handle to Dialog and to each DialogTrigger, with the row's data as payload. Dialog's children become a function of the payload. Triggers can sit anywhere on the page, and focus returns to the one that was pressed. handle.open(), openWithPayload() and close() open and close it from code.
- Ada Lovelaceada@yolkui.com
- Grace Hoppergrace@yolkui.com
- Alan Turingalan@yolkui.com
Unsaved changes
To ask before closing a form with changes, control open and cancel the close in onOpenChange (details.cancel()), then open an Alert Dialog. Put it inside DialogContent: it is then nested, the dialog steps back behind it (a little smaller, lower and dimmer) and only one backdrop covers the page. "Keep editing" returns to the form as it was.
API
Our props on top of Base UI's Dialog (open, defaultOpen, onOpenChange, modal, disablePointerDismissal, actionsRef, handle and the rest work as in Base UI). The width is 448; change it with className.
| Prop | On | Values | Default |
|---|---|---|---|
scroll | DialogContent | "inside" · "outside" | "inside" |
showCloseButton | DialogContent | true · false | true, the × in the corner |
closeLabel | DialogContent | text | "Close", the ×'s name |
showCloseButton | DialogFooter | true · false | false, an outline Close at the end |
closeLabel | DialogFooter | text | "Close", the button's text |
onClick | DialogClose | a function; when it returns a promise the dialog waits for it, and stays open on a rejection or false | — |
loading | DialogClose | true · false: your own work state; the button loads and your code closes the dialog | — |
createDialogHandle() | — | links triggers anywhere to one dialog, with a payload | — |
Accessibility
The popup is a dialog named by its DialogTitle and described by its DialogDescription. Focus moves to the first control inside when it opens (the dialog itself when opened by touch, so no keyboard pops up), Tab and Shift+Tab stay inside, and the page behind is inert and does not scroll.
- Always give it a DialogTitle; when the title should not show, give it className="sr-only".
- The ×'s name is "Close"; closeLabel translates it.
- For a question that needs an answer before anything else happens (deleting, discarding), use Alert Dialog.
Where focus goes
Focus moves to the first control when the dialog opens and back to its trigger when it closes. initialFocus and finalFocus on DialogContent change that: a ref to the element, or false to leave focus where it is. Send it to the field people came for, and after an action that removes the trigger, to where the result shows.
Members
Leaving the page usable
modal={false} on Dialog keeps the page working behind it: no backdrop, no scroll lock, and focus may leave. Place it out of the way with className. modal="trap-focus" keeps focus inside but still leaves the page clickable. Use these for something that stays open while people work; a question or a form wants the default.
Right to left
Under dir="rtl" the × moves to the top left and the actions to the left on their own. The dialog is portaled to the end of the body, so put dir and lang on <html> (or on DialogContent, as this example does inside an LTR page); wrap the app in Base UI's DirectionProvider as for Tabs.