Dialog
Focus a short task over the page while preserving input, a clear exit and an honest task outcome.
Choose the right surface
Use a modal dialog for a bounded task that needs focused attention. Prefer an inline form or dedicated page for long, multi-step work, and AlertDialog for a brief consequential decision. A toast is supplementary feedback, not a modal task surface.
Dialog maps the Material dialog family to installed web primitives. It does not implement native full-screen transitions or operating-system back behavior. At narrow or short viewports, the current popup bounds its height and scrolls; choose a dedicated page when this no longer supports the task.
Anatomy and specifications
| Part | Current CSS |
|---|---|
| Backdrop | Fixed full viewport, 50% black, --z-modal-backdrop |
| Popup | Centered fixed surface; 24px padding; 2px border; --radius-lg; --shadow-xl |
| Sizes | sm 24rem / md 32rem / lg 42rem / xl 56rem maximum widths; md default |
| Viewport | Width viewport minus 2rem; max-height 100dvh minus 2rem; vertical scrolling |
| Title / description | --text-xl weight 700 / --text-base muted |
| Motion | Token-duration scale/fade; explicit animation:none under reduced motion |
| Actions | Caller-owned controls that wrap and remain reachable at short heights |
Material variants, action hierarchy and web adaptation
The reference distinguishes basic dialogs for critical bounded tasks and compact full-screen dialogs for more involved input, with larger windows returning to a basic surface. Its native basic geometry includes 280–560dp width, 28dp corners, 24dp padding, 16dp title/body separation and 8dp action spacing; full-screen uses a 56dp header and square corners. The installed web sizes and bounded scrolling surface are specified separately above. They do not implement a native full-screen variant, FAB transform, predictive back or a platform window.
A native full-screen flow may ask before discarding unsaved changes and can show a basic child dialog; that is not permission to stack arbitrary web modals. The optional local discard policy instead replaces the form with a clear two-action decision inside the same named dialog, retaining its draft and safe initial focus. Keep editing returns to the field; Discard leaves the saved collection unchanged. Clean dismissal never needs the extra decision.
| Reference decision | Local implementation and consuming-product responsibility |
|---|---|
| Purpose, title and priority | Reserve interruption for a task or consequential decision, and use inline feedback for routine status. Use a specific title and explicit action names, rather than an ambiguous question or generic confirmation. Native optional titles do not remove the web requirement for an accessible name. Long names remain available rather than silently truncating at a native line limit. |
| Actions and alignment | Native guidance normally allows acknowledgement alone or confirm plus dismiss, with confirm at the logical trailing edge; stacked native actions use a different order. The local form puts Cancel before Save with Save at the logical trailing edge. At narrow widths it wraps while retaining that reading/focus order, rather than reversing the stacked DOM order. The discard decision exposes exactly Keep editing and Discard edits while hiding the editing controls. Simulated pending outcome controls are test instrumentation, not a recommendation for a five-action production dialog. |
| Validation versus pending | The native basic recommendation disables confirmation until a choice, while its full-screen chapter discourages disabled confirmation. This form keeps Save discoverable, validates on submit and focuses the actual error. Pending keeps an explicit cancellation/outcome path and prevents duplicate application. Field errors are inline and task failures retain input; a network error does not mandate opening another modal here. |
| Scrolling and visibility | Native long-content guidance pins title and actions. The shared bounded popup scrolls as one semantic surface so enlarged titles/actions cannot consume the entire short viewport. Reach the last action with keyboard and scroll, keep background scroll policy consistent with modality and prefer a dedicated page for extensive work. Do not advertise a sticky header/footer contract that the popup does not supply. |
| Role and initial focus | The reference calls basic web dialogs alert dialogs and initially focuses the first interactive element. On this installed web stack, editing uses role=dialog and a labelled field; a consequential confirmation uses AlertDialog with least-destructive initial focus. Long structured information may instead focus a readable heading. Preserve the installed semantics, trap/dismissal policy and final focus rather than applying one native table universally. |
Working edit, validation and recovery
Submit valid input to enter a controlled local pending state. Choose simulated success, failure or cancellation. Failure retains input and offers retry; success alone updates the retained local collection. Escape and outside dismissal are rejected while pending, and cancellation remains available. Enable Confirm discarding local edits before opening to inspect a retained draft decision on an attempted close; the default small-edit policy still discards uncommitted edits on close.
Current local collection: Local collection
'use client';
import { useLayoutEffect, useRef, useState } from 'react';
import { Button, Dialog, DialogBackdrop, DialogClose, DialogDescription, DialogPopup, DialogPortal, DialogTitle, DialogTrigger, Input } from '@mobtranslate/ui';
export function DialogContainmentExample() {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState('Local collection');
const [saved, setSaved] = useState('Local collection');
const [phase, setPhase] = useState<'editing'|'pending'|'failed'>('editing');
const [error, setError] = useState('');
const [size, setSize] = useState<'sm'|'md'|'lg'|'xl'>('md');
const [mode, setMode] = useState<'modal'|'non-modal'|'trap-focus'>('modal');
const [protect,setProtect]=useState(false);
const [discard,setDiscard]=useState(false);
const keepEditing=useRef<HTMLElement>(null);
const input = useRef<HTMLInputElement>(null);
const save = useRef<HTMLElement>(null);
const restoreEditing=useRef(false);
// Focus the newly visible control during commit, before the popup’s removed-focus fallback.
useLayoutEffect(()=>{if(discard)keepEditing.current?.focus();else if(restoreEditing.current){restoreEditing.current=false;input.current?.focus();}},[discard]);
return <div data-dialog-lab className="space-y-4">
<label>Dialog size <select data-dialog-size className="mt-input" value={size} onChange={event => setSize(event.target.value as typeof size)}>{['sm','md','lg','xl'].map(item => <option key={item}>{item}</option>)}</select></label>
<label>Dialog interaction mode <select data-dialog-mode className="mt-input" value={mode} onChange={event => setMode(event.target.value as typeof mode)}>{['modal','non-modal','trap-focus'].map(item => <option key={item}>{item}</option>)}</select></label>
<label className="flex items-center gap-3" style={{minHeight:44}}><input data-dialog-protect type="checkbox" checked={protect} onChange={event=>setProtect(event.target.checked)} style={{width:24,height:24,flexShrink:0}} />Confirm discarding local edits</label>
<Dialog modal={mode === 'modal' ? true : mode === 'non-modal' ? false : 'trap-focus'} open={open} onOpenChange={(next, details) => {
if (!next && phase === 'pending') { details.cancel(); return; }
if(!next&&protect&&draft!==saved){details.cancel();if(discard){restoreEditing.current=true;setDiscard(false);}else setDiscard(true);return;}
setOpen(next);
if (next) { setDraft(saved); setPhase('editing'); setError(''); setDiscard(false); }
}}>
<DialogTrigger render={<Button type="button" />}>Edit local collection</DialogTrigger>
<DialogPortal>{mode === 'modal' && <DialogBackdrop />}<DialogPopup size={size} initialFocus={input} className={state => state.open ? 'local-dialog-open' : ''}>
<DialogTitle>Edit local collection</DialogTitle><DialogDescription>Changes apply to this example only after you confirm a simulated outcome.</DialogDescription>
<form hidden={discard} className="mt-form mt-4" onSubmit={event => {
event.preventDefault();
if (phase === 'pending') return;
if (!draft.trim()) { setError('Enter a collection name.'); input.current?.focus(); return; }
setError(''); setPhase('pending');
}}>
<label>Collection name<Input ref={input} value={draft} readOnly={phase === 'pending'} aria-invalid={!!error || undefined} aria-describedby={error ? 'local-dialog-error' : undefined} onChange={event => { setDraft(event.target.value); setError(''); }} /></label>
{error && <p id="local-dialog-error" role="alert">{error}</p>}
<p role="status">{phase === 'pending' ? 'Local save pending. Choose success or failure below.' : phase === 'failed' ? 'Simulated failure. Your text remains available for retry.' : 'Editing local example; no request will be sent.'}</p>
<div className="flex flex-wrap justify-end gap-3"><DialogClose disabled={phase === 'pending'} render={<Button type="button" variant="outline" />}>Cancel collection edit</DialogClose><Button ref={save} type="submit" aria-disabled={phase === 'pending' || undefined}>Save local collection</Button></div>
{phase === 'pending' && <div className="flex flex-wrap gap-3"><Button type="button" onClick={() => { setSaved(draft.trim()); setPhase('editing'); setOpen(false); }}>Simulate save success</Button><Button type="button" variant="secondary" onClick={() => { setPhase('failed'); save.current?.focus(); }}>Simulate save failure</Button><Button type="button" variant="ghost" onClick={() => { setPhase('editing'); save.current?.focus(); }}>Cancel pending local save</Button></div>}
</form>
{discard&&<div data-dialog-discard className="mt-4 space-y-4"><p role="status">Discard the unsaved local name “{draft}”? The current collection remains “{saved}”.</p><div className="flex flex-wrap gap-3"><Button ref={keepEditing} type="button" style={{paddingInline:12}} onClick={()=>{restoreEditing.current=true;setDiscard(false);}}>Keep editing</Button><Button type="button" style={{paddingInline:12}} variant="outline" onClick={()=>{setDraft(saved);setDiscard(false);setOpen(false);}}>Discard edits</Button></div></div>}
</DialogPopup></DialogPortal>
</Dialog>
<p role="status">Current local collection: {saved}</p>
</div>;
}
Root API
| Root prop | Installed contract |
|---|---|
| open / defaultOpen | Controlled boolean / initial uncontrolled boolean; default false. |
| onOpenChange | (open, eventDetails); details.reason describes cause; details.cancel() can reject a change. |
| onOpenChangeComplete | Called after open/close animations finish; distinct from a submitted task succeeding. |
| modal | Dialog/Drawer: true default; false or trap-focus alternatives. AlertDialog is always modal. |
| disablePointerDismissal | Dialog/Drawer: false default. AlertDialog does not expose this prop and prevents outside-pointer dismissal. |
| actionsRef | Imperative close/unmount lifecycle; do not hold invisible inert content indefinitely. |
| handle / triggerId / defaultTriggerId | Installed primitive trigger association/payload mechanisms; imperative triggers need a meaningful final focus destination. |
| children | Parts or installed payload render function. Root does not render its own DOM node. |
Parts API
Exports: Dialog, DialogTrigger, DialogClose, DialogPortal, DialogBackdrop, DialogPopup, DialogTitle and DialogDescription. DialogPopup adds size=sm|md|lg|xl, md default. Other part contracts come from the pinned @base-ui-components/react 1.0.0-rc.0.
| Part / prop | Contract |
|---|---|
| Trigger | Installed button/render props, disabled, nativeButton, trigger payload/association; button ref. Use render={<Button type="button" />} to compose one control. |
| Close | Installed button/render props and button ref; closes the surface, not an application request. |
| Portal | Mounts the surface outside local layout; preserve theme variables through its actual container. keepMounted retains the subtree, not an open state. |
| Backdrop | Div/render props, className string or state callback, style and div ref; visible dimming belongs to modality. |
| Popup | Div/render props and div ref; initialFocus and finalFocus accept boolean, element ref or interaction-type callback. |
| Title | Installed heading/render props and heading ref; supplies the visible dialog name. |
| Description | Installed paragraph/render props and paragraph ref; supplies a concise purpose/message. |
| className / render / native props | Styled parts retain primitive state callbacks. Custom render must preserve forwarded props, semantics and refs. |
Focus, keyboard and dismissal
- Choose initial focus deliberately: a labelled field for a small edit, a focusable heading for long structured content, or the least destructive action for a consequential choice.
- Modal Tab/Shift+Tab remain inside; Escape closes when application policy permits. Always include a visible named close or cancel action.
- Return to the invoking control, or a meaningful enabled alternative when that control is removed or disabled by the result.
- Do not make long semantic content one flattened description. Use a concise description and retain headings, lists and paragraphs for normal reading.
- A pending operation can prevent accidental dismissal only when a real cancellation/recovery path remains. Closing the surface is not cancelling a network request.
- Non-modal use needs an explicit workflow and keyboard route between the surface and page. Do not claim modal blocking for modal=false or pointer blocking for trap-focus. Styled popup wrappers expose aria-modal only for fully modal roots; omit a dimming backdrop for non-modal/focus-only layouts.
Task states and failures
- Editing: explicit labels, current draft and cancel. Invalid: focus the invalid field and connect its concise error; retain other input.
- Pending: prevent duplicate submission and retain a real cancellation/outcome path. Do not silently drop a response or claim that closing aborts the operation.
- Failed: retain the draft, explain the failure and return to an enabled retry action. Complete: apply the actual result before closing and restoring focus.
- Opening without a name/description, losing theme variables in the portal, or scrolling the page instead of the popup are implementation failures.
- Programmatically closing after success is different from a close-button press; both must leave an enabled meaningful focus destination.
Verification and release scope
- Verify the actual title, description relationships, focus destination, keyboard operation and task result in the consuming application.
- Exercise narrow and short viewports, both themes, long labels, text spacing, reduced motion and forced colors. Reach the last action by scrolling and keyboard.
- For overlays, check background interaction, scroll locking and each permitted dismissal path. A role or aria-modal attribute alone does not prove modal behavior.
- Use a named screen reader and browser for spoken output. Local examples and automated DOM checks do not prove announcements or a persisted backend result.
- Retain source/review/permission evidence. A container or confirmation cannot grant authority or establish that a request succeeded.