Dialog
A popup that opens on top of the entire page.
Name: Pedro Duarte
Codeapp/demos/public/dialog.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { buttonVariants } from '@stemsui/registry/ui/button.tsx'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@stemsui/registry/ui/dialog.tsx'
import { Input } from '@stemsui/registry/ui/input.tsx'
import { Label } from '@stemsui/registry/ui/label.tsx'
export const DialogDemo = clientEntry(import.meta.url, function DialogDemo(handle: Handle) {
let saved = 'Pedro Duarte'
// Controlled fields; `saved` is updated when the form is submitted.
let name = saved
let username = '@peduarte'
return () => (
<div class="flex flex-col items-center gap-4">
<Dialog>
<DialogTrigger class={buttonVariants({ variant: 'outline' })}>Edit profile</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<form
class="grid gap-6"
mix={on('submit', (event) => {
event.preventDefault()
saved = name
void handle.update()
})}
>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<div class="grid gap-4">
<div class="grid gap-3">
<Label for="dialog-demo-name">Name</Label>
<Input
id="dialog-demo-name"
name="name"
value={name}
onValueChange={(value) => {
name = value
void handle.update()
}}
/>
</div>
<div class="grid gap-3">
<Label for="dialog-demo-username">Username</Label>
<Input
id="dialog-demo-username"
name="username"
value={username}
onValueChange={(value) => {
username = value
void handle.update()
}}
/>
</div>
</div>
<DialogFooter class="sm:justify-between">
<Dialog>
<DialogTrigger
type="button"
class={buttonVariants({ variant: 'ghost' })}
data-testid="nested-trigger"
>
Privacy settings
</DialogTrigger>
<DialogContent class="sm:max-w-sm" data-testid="nested-dialog">
<DialogHeader>
<DialogTitle>Privacy settings</DialogTitle>
<DialogDescription>
This dialog is nested: Escape or a click outside closes only this one.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
<div class="flex flex-col-reverse gap-2 sm:flex-row">
<DialogClose type="button" class={buttonVariants({ variant: 'outline' })}>
Cancel
</DialogClose>
<DialogClose type="submit" class={buttonVariants()}>
Save changes
</DialogClose>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<p class="text-sm text-muted-foreground" data-testid="dialog-result">
Name: <span class="font-medium text-foreground">{saved}</span>
</p>
</div>
)
})
Installation
npx shadcn add @stemsui/dialogRequires the one-time setup. Behavior: stems-ui/dialog.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/dialog
dialog turns a native <dialog> element into a Base UI–style dialog: trigger wiring, modal and
non-modal modes, enter/exit transitions, Escape and outside-press dismissal through the shared
dismiss stack, focus management, scroll lock, and nested-dialog state. App code owns the markup
and styles.
Base UI equivalent: Dialog.
Usage
import * as dialog from 'stems-ui/dialog'
function EditProfile() {
return () => (
<dialog.Context>
<button mix={dialog.trigger()}>Edit profile</button>
<dialog
class="m-auto rounded-xl p-6 open:grid backdrop:bg-black/10 data-starting-style:opacity-0 data-ending-style:opacity-0 transition-opacity"
mix={dialog.popup()}
>
<h2 mix={dialog.title()}>Edit profile</h2>
<p mix={dialog.description()}>Make changes to your profile here.</p>
<input name="name" />
<button mix={dialog.close()}>Close</button>
</dialog>
</dialog.Context>
)
}
Control the state with open and onOpenChange, or let the context own it with defaultOpen.
Listen for bubbling changes on the popup or an ancestor:
<div mix={dialog.onDialogChange((event) => console.log(event.open, event.reason))}>…</div>
Base UI's Portal and Viewport parts have no Stems equivalent: the <dialog> is rendered where
the app puts it and escapes clipping and stacking through the browser's top layer.
stems-ui/dialog
Context: provider that owns the open state.open:boolean. Controlled open state.defaultOpen:boolean. Initial uncontrolled state. Defaults tofalse.onOpenChange(open, details): called before the state changes.detailshasreason,event,trigger,cancel(), andisCanceled. Callingdetails.cancel()keeps the current state, and no change event is dispatched.onOpenChangeComplete(open): called after the enter or exit animation has finished.modal:true | false | 'trap-focus'. Defaults totrue.true:showModal(). The page is inert, Tab is trapped, page scroll is locked (lib/scroll-lock.ts), andaria-modal="true"is set.'trap-focus':show(), with Tab trapped inside the popup. The page stays scrollable and clickable.false:show(). Nothing is trapped. Focus moving outside the popup closes it (reason'focus-out').
disablePointerDismissal:boolean. Outside presses (and focus moving outside, for non-modal dialogs) don't close the dialog. Escape still does. The value is read when the dialog opens. Defaults tofalse.actionsRef:{ current }object. Receives{ open(), close() }(reason'imperative-action').
trigger(options?): the open button. Setstype="button",aria-haspopup="dialog",aria-expanded, andaria-controls. Pressing it toggles the dialog (reason'trigger-press'). Non-<button>hosts getrole="button",tabIndex, and Enter/Space activation.disabled:boolean. Sets nativedisabled(oraria-disabled) and ignores presses.
popup(options?): the<dialog>element. Setsid,aria-labelledby,aria-describedby,aria-modal(modal only), and the state attributes and variables below. The host must be a<dialog>.initialFocus: where focus goes when the dialog opens. The default is the first[autofocus]or tabbable element, or the popup itself when the dialog was opened by touch.false: leave focus where the browser put it.- An element: focus it.
- A CSS selector: focus the first match inside the popup. Remix has no refs, so this
replaces Base UI's
RefObject. - A function
(interactionType) => element | boolean | null | void: return an element to focus,trueornullfor the default, orfalse/undefinedto leave focus alone.
finalFocus: where focus goes after the dialog closes. Takes the same values asinitialFocus, but selectors are resolved in the document. The default is the trigger that opened the dialog, or else the element that was focused before it opened. Focus is only moved if it was still inside the dialog (or on<body>), so an outside press onto another control keeps focus there. Withfalse, Stems doesn't move focus, but the browser's owndialog.close()may still return focus to the element that was focused before the dialog opened.
backdrop(options?): an optional separate backdrop element. It getshiddenwhile the dialog is closed and while its exit animation runs, and presses on it count as outside presses. See "Backdrops" below.forceRender:boolean. Render the backdrop even when the dialog is nested. Defaults tofalse(Base UI hides nested backdrops).
title(): labels the popup. Sets a generatedid.description(): describes the popup. Sets a generatedid.close(options?): a button that closes the dialog (reason'close-press'). Same host handling astrigger(), includingdisabled.onDialogChange(handler): listener mixin forDialogChangeEvent(stems:dialog-change, bubbles, dispatched from the popup, hasopenandreason).
Change reasons: 'trigger-press', 'outside-press', 'escape-key', 'close-press',
'focus-out', 'imperative-action', 'close-watcher', 'none'. 'close-watcher' is a Stems
addition: it covers close requests that aren't an Escape key press, such as the Android back
gesture.
Detached triggers
Base UI's createHandle(): triggers can live anywhere in the document, outside the Context,
linked by a handle. Each trigger can pass a payload, so one dialog can serve many triggers with
different data.
import * as dialog from 'stems-ui/dialog'
let editUser = dialog.createHandle<{ id: string; name: string }>()
// Anywhere, e.g. in each table row:
<button mix={dialog.trigger({ handle: editUser, payload: user })}>Edit</button>
// Once, elsewhere:
<dialog.Context handle={editUser}>
{({ payload }) => (
<dialog mix={dialog.popup()}>
<h2 mix={dialog.title()}>Edit {payload?.name}</h2>
</dialog>
)}
</dialog.Context>
createHandle<Payload>()returns the handle. Pass it to theContext(handleprop, read once) and to each detachedtrigger({ handle, payload })(read on the trigger's first render).Payloadtypespayloadin theContext's children function.- Detached triggers register with the handle, so they behave like triggers inside the
Context:aria-expanded,aria-controls, anddata-popup-openfollow the dialog, focus returns to the trigger that opened it, and (for non-modal dialogs) presses on any trigger count as inside. - The payload is
{ payload }in a children render function (re-run each time theContextrenders) andpayloadon the context value. It's the active trigger'spayload, or the one given toopenWithPayload(). A children function only works with JSX (createElementrejects function children). - Handle methods:
isOpen,open(triggerId?)(optionally associated with the trigger with thatid),openWithPayload(payload)(no trigger; focus returns to the element focused before opening), andclose(), all with reason'imperative-action'. They act on the mountedContextand are ignored while none is mounted. ContextpropstriggerId(controlled, pair withopen; storedetails.trigger?.idfromonOpenChange) anddefaultTriggerIdpick the active trigger by elementid.- Detached triggers aren't re-rendered by the
Context; they update their state attributes directly when it renders. On the server nothing is shared, so a detached trigger renders closed and withoutaria-controls. - The same handle type works for
stems-ui/alert-dialogandstems-ui/drawer, which export their owncreateHandle().
Behavior notes
- Native
<dialog>. A closed dialog stays in the DOM and is hidden by the UA stylesheet (dialog:not([open]) { display: none }). Display classes must therefore be gated on the open state, for exampleopen:gridinstead ofgrid. The server renders the state attributes, but it does not render theopenattribute: a modal dialog can only be shown withshowModal(), so adefaultOpendialog appears once the page hydrates. - Transitions. On open, the dialog is shown in the same commit that adds
data-starting-style, and the attribute is removed on the next frame. On close,data-closedanddata-ending-styleare set, anddialog.close()only runs after the popup's own CSS transitions and animations have finished. Reopening during the exit cancels it. - Escape. Escape goes through
lib/dismiss-layer.ts. The stack cancels thekeydown, so the browser never sends its own close request. This was verified in Chromium with real key presses. The dialog'scancelevent is always cancelled as well. If a close request still arrives without an Escapekeydown(Android back,CloseWatcher), it closes the dialog only when the dialog is the topmost layer (reason'close-watcher'). A popover or menu inside the dialog closes first, because it registered its layer later. - Outside presses. The page behind a modal dialog is inert, so presses land on the
<dialog>element itself. A press outside the dialog's border box (the::backdrop) is an outside press. A press inside it, including on padding, is not. Presses on abackdrop()element are always outside presses. The trigger and nested dialogs count as inside. - Native closes. If the
<dialog>closes without Stems (<form method="dialog">, or app code callingdialog.close()), the state follows with reason'none'. If the owner cancels that change or keeps the dialog controlled open, the dialog is shown again. - Focus.
modal: trueand'trap-focus'wrap Tab and Shift+Tab inside the popup. Nested open dialogs handle their own Tab. - Nested dialogs. A
Contextinside another dialog'sContextis nested. This includes alert dialogs and drawers. The child popup getsdata-nested. The parent popup getsdata-nested-dialog-openand--nested-dialogs(the depth of the open chain inside it) while a child is open. These clear as soon as the child starts closing. Escape and outside presses close only the innermost dialog. - Explicit host props win (
attrs()semantics). Keep the generated popup, title, and description ids so the ARIA links stay intact. A hostidontitle()/description()is picked up when the part first renders.
Backdrops
A sibling element can't sit between a top-layer <dialog> and its ::backdrop, so there are two
ways to draw a backdrop:
::backdrop(recommended for modal dialogs). Style it with Tailwind'sbackdrop:variant on the popup. It shares the popup's state attributes, for exampledata-open:backdrop:animate-in data-closed:backdrop:animate-out. In current browsers::backdropalso inherits the popup's custom properties. The exit wait only tracks the popup's own animations, so keep backdrop animations no longer than the popup's. Hide the backdrops of nested dialogs withdata-nested:backdrop:bg-transparent.backdrop()element. Use this for non-modal dialogs that want a visible scrim, or for Base UI parity. Render it before the<dialog>, for example asfixed inset-0. It is not in the top layer, so make sure no ancestor clips it. Under a modal dialog it sits beneath the::backdrop.
Styling hooks
| Part | Attribute / variable |
|---|---|
trigger() | aria-expanded, data-popup-open, data-disabled |
popup() | :open / [open], data-open / data-closed, data-starting-style / data-ending-style, data-nested, data-nested-dialog-open, --nested-dialogs |
backdrop() | hidden, data-open / data-closed, data-starting-style / data-ending-style |
close() | data-disabled |
Not yet supported
actionsRef.unmount()anddetails.preventUnmountOnClose(). The popup is app-owned markup and is never unmounted, and Stems always waits for CSS animations before closing.details.allowPropagation().