Skip to main content

Dialog

Open a labelled modal, manage confirmation state, and return focus to a useful place.

7 min read

Use a dialog when someone needs to complete a focused task or make a decision before returning to the current page. Dialog supplies the modal structure; your application supplies its state, content, and callbacks.

tsx
import { Dialog } from '@tale/ui/dialog/dialog';
import { ConfirmDialog } from '@tale/ui/dialog/confirm-dialog';

Open and close a dialog

Live example

Choose Invite a member, enter a sample email, and press Escape or Cancel. Focus returns to the opener. Send invitation only closes this local example; it sends no email and performs no validation or persistence.

open, onOpenChange, and title are required by Dialog. Pass a trigger when the opener is available in the same composition. Otherwise update open from your own button or menu and let the dialog capture the active opener.

The title is the accessible name. description gives context under it. Place required instructions where they remain visible, and label every form control independently. The footer is caller-owned: dismissing action first, confirming action second.

Explain a consequential decision

Live example

Nothing happens until you confirm.

Choose Delete project, then cancel or confirm. Confirmation updates only the example's local message. The sample consequence text demonstrates where an application would explain its own deletion rules; it is not a specification of Tale project deletion.

ConfirmDialog renders Cancel and Confirm actions for you. confirmText and cancelText override shared translated defaults. variant selects default, destructive, or warning styling; it does not implement the underlying operation.

Your onConfirm handler owns the request and closing behavior. Set isLoading while it runs: confirmation, cancellation, and close requests are blocked until it settles. On failure, keep the decision context and explain the problem. disableConfirm disables confirmation without disabling cancellation.

For a type-to-confirm decision, set requireConfirmPhrase. The trimmed input must match the phrase exactly, including case. It resets when the dialog opens again. This deliberate UI step is additional confirmation, not a substitute for authorization.

Choose the right wrapper

Component subpathUse
dialog/dialogCustom content and footer.
dialog/confirm-dialogA decision with paired cancel/confirm actions.
dialog/delete-dialogEntity-specific deletion wording.
dialog/form-dialogA form with submission state and actions.
dialog/view-dialogRead-only detail.
entity/entity-view-dialogOne record's details in the shared record layout, with an edit handoff.
overlays/responsive-dialogThe responsive wrapper's dialog/drawer composition.

The base Dialog itself uses a bottom-sheet layout below md and a centered modal above it. Its header and footer remain outside the scrollable body. Test long content on a phone; choosing a large desktop size does not remove the need for that check.

Put actions beside Close

Live example

Open the example and look at the top-right corner: Copy link and Open as page sit in one cluster with Close. Copy link only changes the example's text; Open as page points at an anchor on this page.

ResponsiveDialogContent takes headerActions for icon actions that belong to the dialog's chrome rather than to its content, such as opening a record on its own page. They render in one cluster before Close, on the centred dialog and on the phone's drawer alike, and follow the content in the tab order, so Close stays the last stop. Give each action its own accessible name, make an action that navigates a real link, and leave room for the cluster on the content's first row. The close control reads the shared translated "Close" unless closeLabel names a more specific verb; hideClose removes it on both layouts.

Keep record details scannable

EntityViewDialog uses the lg reading width and sizes its height to the content. The record name, status and actions form one header; the generic title remains available to assistive technology. Use name for the main heading, a short summary only when it adds information, and content for the primary reading area. Omit generic descriptions that repeat the dialog title.

facts renders aligned label/value rows; items with colSpan: 2 use the full width for longer content. Put pages or version history in EntityViewSection, and pass identifier={{ label, value, hint? }} for a compact copyable ID after all sections; hint is a caption saying what the value does and does not identify. Give a section focusRef when the host moves focus into it — for instance when a retry replaces the control that held focus — so focus lands on the named section rather than the dialog. Keep descriptions in one place rather than repeating them in the summary and body.

Base dialog options

PropPurpose
sizesm, default, md, lg, xl, 3xl, entity, or wide; default default. entity gives record forms a shared width and content-driven height. EntityViewDialog uses the wider lg reading measure.
children, footerBody and action content; either may be omitted.
icon, headerActionsAdditional header content.
onBack, backLabel, backDisabledA labelled back control for an in-dialog subview. Set backDisabled while the subview can't be left, such as during a save that also disables its Cancel.
customHeaderReplaces the visible header; the required title remains available to assistive technology.
hideCloseHides the close control; provide an accessible dismiss path unless the current operation deliberately blocks it.
className, headerClassName, bodyClassName, footerClassNameTargeted layout adjustments.
restoreFocusRefStable fallback when the captured opener unmounts or moves, for example a toolbar button the first row replaces. A dialog opened from a menu item needs none: it returns to that menu's button.
preventCloseAutoFocusOpt out of automatic restoration only when the caller explicitly manages the next focus target. ResponsiveDialogContent takes it too, for a close that hands the reader on to another panel.
onCloseAutoFocusCalled after the close focus handler runs. Available on Dialog, ConfirmDialog and DeleteDialog; use it to release a retained virtual row after focus returns to its opener.

Widgets that use Escape

A widget inside a dialog may use Escape itself: a code editor closes its completion list, then arms leaving the field. The dialog hears Escape first, so every @tale/ui overlay (Dialog, ResponsiveDialog, Sheet, Popover, DropdownMenu) leaves an Escape alone when an element around the focus carries data-claims-escape: it cancels the event and stays open.

Set the attribute while your widget needs Escape and remove it once it does not; the next Escape then closes the layer. A cancelled event still reaches your widget, but some libraries skip cancelled events. Such a widget handles a claimed Escape before the overlay does, in a capture listener on window. CodeEditor does all of this itself.

Handle lifecycle and focus deliberately

The modal traps focus while open. Escape and the close control request dismissal; controlled state determines whether the request is accepted. Restore focus to a useful surviving control after close, especially when a successful action removes the original row.

A return point counts only if it can still take the focus: an opener, restoreFocusRef or menu button that was removed or disabled is skipped. When none is left, as when a confirmed delete took the row, the dialog asks the surviving part of the page. It dispatches RESTORE_FOCUS_LOST_EVENT (from @tale/ui/use-restore-focus), bubbling and cancelable, from the nearest ancestor of the lost return point that is still in the document. DataTable answers by focusing the next row's control. A host that knows a better target can do the same: focus it and cancel the event. If nothing answers, focus goes to the nearest surviving ancestor that accepts programmatic focus (tabIndex={-1}), such as a list's named region or the page's <main>.

A successful return also keeps DataTable tracking the restored row when the browser page is unfocused and emits no native focusin. A later refresh that removes that row still moves focus to its successor. Moving focus elsewhere clears that ownership.

Content can remain mounted through a closing animation. Do not assume open=false immediately stops its subscriptions or requests. If hook-heavy content has a closing-lifecycle problem, move it into a separate component and conditionally mount that component; do not call hooks conditionally inside one component.

Use an inline error for repairable form problems and a toast for an optional completion notice. Use a persistent page or side panel when the task needs more room or the reader needs to refer to the surrounding content continuously.