Built on <dialog>, showModal() and Invoker Commands. The platform supplies the top layer, the backdrop, the inert page, Escape, initial focus and focus return. The library adds the Async React half: opening a dialog whose content loads is a transition, so the trigger shows data-pending and the dialog opens once its content is ready.
// No "use client": this file is a Server Component. The Dialog.Root has no `open`, `defaultOpen`
// or `openChangeAction`, so it runs in JS-free mode: the Trigger is a native
// `command="show-modal"` invoker, Cancel is a `command="request-close"` button and Delete submits
// a `<form method="dialog">`. The browser opens, focuses, closes on Escape and returns focus,
// before hydration and with JavaScript off.
import { Dialog } from "@funstack/ui/dialog";
export function DialogConfirm() {
return (
<Dialog.Root>
<Dialog.Trigger className="demo-button">Delete draft…</Dialog.Trigger>
<Dialog.Popup className="demo-dialog" role="alertdialog">
<Dialog.Title>Delete this draft?</Dialog.Title>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Form className="demo-dialog-actions">
<Dialog.Close className="demo-button" value="cancel">
Cancel
</Dialog.Close>
<button type="submit" className="demo-button demo-danger" value="delete">
Delete
</button>
</Dialog.Form>
</Dialog.Popup>
</Dialog.Root>
);
}/* Dialog: the Popup is the <dialog> itself. Open state is `:open` / `:modal` on it (exact in
both modes); the Trigger carries `data-open` / `data-pending` in library mode only. */
/* CSS-only scroll lock, keyed on :modal: covers dialogs opened before hydration and nested ones. */
html:has(.demo-dialog:modal) {
overflow: hidden;
}
.demo-dialog {
inline-size: min(28rem, calc(100vw - 32px));
padding: 20px 24px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
overscroll-behavior: contain;
}
.demo-dialog::backdrop {
background: rgb(0 0 0 / 0.45);
}
.demo-dialog h2 {
margin: 0 0 8px;
padding: 0;
border: 0;
font-size: 1.2rem;
}
.demo-dialog p {
margin: 0 0 12px;
color: var(--color-text-secondary);
}
/* Enter and exit with CSS, both modes. Enter works everywhere; the exit animates in Chromium
(with `overlay`) and Safari and is instant in Firefox. */
.demo-dialog,
.demo-dialog::backdrop {
opacity: 0;
transition:
opacity 150ms,
translate 150ms,
display 150ms allow-discrete,
overlay 150ms allow-discrete;
}
.demo-dialog {
translate: 0 8px;
}
.demo-dialog:open,
.demo-dialog:open::backdrop {
opacity: 1;
translate: 0 0;
}
@starting-style {
.demo-dialog:open,
.demo-dialog:open::backdrop {
opacity: 0;
}
.demo-dialog:open {
translate: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-dialog,
.demo-dialog::backdrop {
transition: none;
}
}
.demo-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.demo-dialog-form label {
display: grid;
gap: 4px;
}
.demo-dialog-form input {
padding: 0.35em 0.6em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
.demo-dialog-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
margin: 0;
}
.demo-dialog-details dd {
margin: 0;
}
/* The Trigger in library mode: optimistic open and pending. */
.demo-button[data-open] {
border-color: var(--color-primary);
color: var(--color-primary);
}
.demo-button[data-pending] {
cursor: progress;
}
ul.demo-list {
margin: 0 0 12px;
padding: 0;
list-style: none;
}
ul.demo-list .demo-row {
justify-content: space-between;
max-inline-size: 20rem;
padding-block: 4px;
}This confirmation is a Server Component in JS-free mode. Every button is a native invoker or a method="dialog" submit, so it works before hydration and with JavaScript disabled.
import { Dialog } from "@funstack/ui/dialog";
<Dialog.Root> {/* no DOM: ids, mode, state */}
<Dialog.Trigger>Edit profile</Dialog.Trigger> {/* button[commandfor][command=show-modal] */}
<Dialog.Popup> {/* dialog */}
<Dialog.Title>Edit profile</Dialog.Title> {/* h2, wired to aria-labelledby */}
<Dialog.Description>…</Dialog.Description> {/* p, wired to aria-describedby */}
<Dialog.Form action={saveProfile}> {/* form: closes after the action settles */}
<input name="name" />
<Dialog.Close>Cancel</Dialog.Close> {/* button[commandfor][command=request-close] */}
<button type="submit">Save</button>
</Dialog.Form>
</Dialog.Popup>
</Dialog.Root>commandfor links them by id. A Popup without a Trigger is fine (controlled open); a Trigger without a Popup is a development warning.<dialog> itself, so it carries ::backdrop. There is no Portal, Backdrop or Positioner part: the top layer makes them unnecessary. Nothing inside the dialog portals out of it.render: Popup must render a <dialog>, Form a <form>. Trigger and Close must render a <button> for the command path; another element works only through a click handler (Trigger in library mode, Close with action). Wrong elements are development warnings.| JS-free mode | Library mode | |
|---|---|---|
| Selected when | Root has none of open, defaultOpen, openChangeAction | Root has any of them |
| Open | the browser runs command="show-modal" | the click is intercepted and dispatched in the Root's transition; showModal() runs in the commit that mounts the content |
| Close | the browser | the browser; openChangeAction(false) is a notification |
| Popup content | always mounted (in the server HTML) | rendered from committed open; unmounted while closed unless keepMounted |
Trigger data-open, data-pending | not emitted; style :open on the dialog | emitted (optimistic) |
| Before hydration | everything works | the native open happens; the content arrives on hydration (see below) |
transition on Popup | no effect (development warning) | animates the opening commit and content swaps |
Open state in library mode is owned by the library in one direction only: opening is a transition the library drives and awaits; closing is performed by the browser and reported afterwards. See Browser-owned state. The mode is decided on the first render; switching later is not supported (development warning).
Dialog.Root| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | – | Controlled open state. undefined means uncontrolled. Selects library mode. |
defaultOpen | boolean | – | Initial open state in uncontrolled library mode. A dialog that starts open is shown after hydration, never by the server HTML; its content and the Trigger's open state are already in that HTML. defaultOpen={false} is the usual way to select library mode without an action. |
openChangeAction | ChangeAction<boolean, DialogOpenChangeDetails> | – | Awaited for true: the dialog opens when it resolves and the content has rendered. A notification for false: the dialog is already closed when it runs. Runs in the Root's transition; may be a Server Function; rejections reach the nearest Error Boundary above the Root. details: reason ("activate", "dismiss" or "platform") and returnValue (the closing button's or submitter's value, "" for a dismiss, undefined on open). Selects library mode. |
modal | boolean | true | showModal() (top layer, backdrop, inert page, Escape) or show(). A non-modal dialog has no command to open it, so its Trigger always uses a click handler: it is never JS-free. |
children | ReactNode | ((state: DialogRootState) => ReactNode) | – | The parts. The function form receives the optimistic state and must not suspend. |
DialogRootState: { open, pending, modal }, where open is optimistic and both are false in JS-free mode. The Root has no ref, className or style.
Dialog.Trigger| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Native disabled: a disabled button is not an invoker and never opens. |
className | string | ((state: DialogTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: DialogTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: DialogTriggerState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((props, state: DialogTriggerState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
DialogTriggerState: { open, pending, disabled }. pending is true while the Root is pending: an open or a close notification in flight, or a re-open requested while a close notification is still settling. The Trigger owns type, disabled, commandfor, command and, when it installs one, onClick.
Dialog.Popup| Prop | Type | Default | Description |
|---|---|---|---|
keepMounted | boolean | false | Library mode. false: children are not rendered while closed, so content that suspends holds the open until it is ready. true: children stay in <Activity mode="hidden"> (state kept, effects stopped, pre-rendered at low priority; not in the server HTML). Either way, unmounting waits for the dialog's exit animation. Ignored in JS-free mode (always mounted). |
initialFocus | RefObject<HTMLElement | null> | string | – | The element to focus on open: a ref, or a selector resolved inside the dialog. The library writes autofocus on it right before showModal() and lets the platform focus it; it never calls .focus(). Default: the platform's choice (the first focusable element). In JS-free mode the attribute is written after mount; prefer it to autoFocus, which React writes only into server-rendered HTML (not when the Popup renders on the client, for example after a client navigation to a Server Component page). |
lightDismiss | boolean | false | Close on a click outside the dialog (the ::backdrop for a modal). JS pointerdown and click listeners: both the press and the click must land outside, so a drag that starts inside (selecting text) never closes. With experimental_closedBy, native closedby="any", which behaves the same. |
experimental_closedBy | boolean | false | Renders closedby ("any" with lightDismiss, else "closerequest") and skips the JS dismiss listeners where the browser implements it (Chromium, Firefox). Elsewhere the JS stays. See Experimental features. |
transition | ViewTransitionClass | – | Library mode only. Forwarded to the <ViewTransition> around the dialog; without it no boundary is rendered. It animates the opening commit and content swaps while open. The close is the browser's; animate it with CSS. |
className | string | ((state: DialogPopupState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: DialogPopupState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: DialogPopupState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: DialogPopupState) => ReactNode) | – | Replace the default <dialog> (element form) or take over rendering (function form). |
DialogPopupState: { open, modal }, with open committed (false in JS-free mode; :open is the DOM truth). The Popup owns id, open (never rendered: opening goes through showModal() only), closedby, onToggle and onBeforeToggle; its aria-labelledby and aria-describedby are merged with yours. Other props, such as role="alertdialog" or onCancel, pass through.
Dialog.Close| Prop | Type | Default | Description |
|---|---|---|---|
action | Action<[details: DialogCloseDetails]> | – | Runs in this button's own transition (data-pending on it), then closes the dialog with close(value). A rejection reaches the nearest Error Boundary above the button and leaves the dialog open. Without action the button is a native command="request-close" invoker. details.returnValue is the value. |
value | string | – | Becomes dialog.returnValue, and details.returnValue of the close notification. |
disabled | boolean | false | Native disabled. |
className | string | ((state: DialogCloseState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: DialogCloseState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: DialogCloseState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: DialogCloseState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
DialogCloseState: { pending, disabled }. Repeated clicks while pending re-run the action; the first close wins. The Close owns disabled, so a render element cannot re-enable it.
Dialog.Form| Prop | Type | Default | Description |
|---|---|---|---|
action | string | ((formData: FormData) => unknown) | – | Without action the form renders method="dialog": a submit closes the dialog natively with the submitter's value. A function (may be a Server Function) is React's form action, passed through unwrapped; the dialog closes once the action's transition has settled, including any Suspense wait it causes (see closeOnSettle). A rejection leaves the dialog open. A string is a plain navigating form. |
closeOnSettle | boolean | true | With a function action: close the dialog once the action settles without throwing. Any non-throwing result counts, including a useActionState result that reports validation errors; set false to keep the dialog open and close it yourself (a Dialog.Close, a formMethod="dialog" submitter, or a controlled open). The dialog is never closed when it was closed while the action ran, even if the user re-opened it meanwhile. |
method | string | – | Only with a string action (for example "post"). Otherwise owned: "dialog" without action, React's own with a function. |
className | string | ((state: DialogFormState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: DialogFormState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: DialogFormState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: DialogFormState) => ReactNode) | – | Replace the default <form> (element form) or take over rendering (function form). |
DialogFormState: { pending }, this form's useFormStatus().pending. It is not emitted as data-pending and not folded into any other part's state. The Form owns action, and method unless action is a string.
Dialog.Title, Dialog.Description| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: DialogTitleState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: DialogTitleState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: DialogTitleState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: DialogTitleState) => ReactNode) | – | Replace the default <h2> (Title) or <p> (Description). render={<h3 />} changes the heading level. |
Both own their id. Their state is the Popup's (DialogTitleState and DialogDescriptionState are DialogPopupState).
| Hook | On | Meaning |
|---|---|---|
:open | Popup | The dialog is open (DOM truth, both modes). |
:modal | Popup | Opened with showModal(). |
::backdrop | Popup | The modal backdrop. |
data-modal | Popup | The modal prop (also before the dialog opens). |
data-open | Trigger | Optimistic open state (library mode only). |
data-pending | Trigger | An open, or a close notification, is in flight (library mode only). |
data-pending | Close | Its action is in flight. |
data-disabled | Trigger (library mode), Close | The disabled prop. |
The <dialog> emits no data-open: :open and :modal are exact. Enter and exit animate in CSS, in both modes:
.dialog, .dialog::backdrop {
opacity: 0;
transition: opacity 150ms, display 150ms allow-discrete, overlay 150ms allow-discrete;
}
.dialog:open, .dialog:open::backdrop { opacity: 1; }
@starting-style { .dialog:open, .dialog:open::backdrop { opacity: 0; } }
/* Scroll lock, CSS only: keyed on :modal, so it covers pre-hydration opens and nested dialogs. */
html { scrollbar-gutter: stable; }
html:has(dialog:modal) { overflow: hidden; }The exit animates in Chromium and Safari; Firefox closes instantly. The library keeps the content mounted until the exit animation ends, so a fading dialog is never empty.
| Key | Behaviour | Who |
|---|---|---|
| Enter / Space on the Trigger | Opens | the browser (JS-free) or the Trigger |
| Tab / Shift+Tab in a modal | Cycles through the dialog and the browser UI; the page is inert | the browser |
| Escape (modal) | Closes and returns focus; openChangeAction(false, { reason: "dismiss" }) | the browser |
| Escape (non-modal) | Closes with requestClose() | the Popup (or closedby with the flag) |
| Escape in a nested dialog | Closes the innermost one only | the browser |
| Enter in a form field | Submits the Form: closes, or runs the action and then closes | the browser / Form |
dialog; aria-modal is exposed by the browser after showModal(), never set by hand. role="alertdialog" passes through. aria-labelledby and aria-describedby point at Title and Description. A Popup with neither a Title nor an aria-label is a development warning.showModal() makes the rest of the page inert. Focus return is the platform's; the library only steps in when the opener has been removed (focus goes to the Trigger if it is still there, else to the body).initialFocus pointing at a <Dialog.Title tabIndex={-1}>.aria-expanded, no aria-haspopup. Give an icon-only Close an aria-label."use client";
import { use, useState } from "react";
import { Dialog } from "@funstack/ui/dialog";
import type { DialogOpenChangeDetails } from "@funstack/ui/dialog";
import { sleep } from "./fake-api.js";
interface Order {
readonly items: string;
readonly total: string;
}
// Cache the promises so `use()` sees the same one on every render.
const cache = new Map<string, Promise<Order>>();
function loadOrder(id: string): Promise<Order> {
let promise = cache.get(id);
if (promise === undefined) {
promise = sleep(1200).then(() => ({
items: id === "1042" ? "Keyboard, USB-C cable" : "Notebook",
total: id === "1042" ? "$129.00" : "$18.50",
}));
cache.set(id, promise);
}
return promise;
}
function OrderDetails({ id }: { id: string }) {
const order = use(loadOrder(id));
return (
<dl className="demo-dialog-details">
<dt>Items</dt>
<dd>{order.items}</dd>
<dt>Total</dt>
<dd>{order.total}</dd>
</dl>
);
}
// Library mode: `defaultOpen={false}` (or any action prop) hands the open to the library. The
// Popup's children render from committed state, so <OrderDetails> mounts inside the opening
// transition and suspends: the dialog stays closed and the Trigger carries data-pending until the
// data is ready, then the dialog opens once, complete. Closing is the browser's; the readout shows
// the notification the Root receives.
export function DialogOrders() {
const [last, setLast] = useState("(none yet)");
const record = (open: boolean, { reason, returnValue }: DialogOpenChangeDetails) => {
// The synchronous part of an action: this setState is a transition update.
setLast(`open=${open} reason="${reason}" returnValue=${JSON.stringify(returnValue)}`);
};
return (
<>
<ul className="demo-list">
{["1042", "1043"].map((id) => (
<li key={id} className="demo-row">
Order #{id}
<Dialog.Root defaultOpen={false} openChangeAction={record}>
<Dialog.Trigger className="demo-button">
{({ pending }) => (
<>
Details
{pending && <span className="demo-spinner" aria-hidden />}
</>
)}
</Dialog.Trigger>
<Dialog.Popup className="demo-dialog" lightDismiss initialFocus="[data-autofocus]">
<Dialog.Title>Order #{id}</Dialog.Title>
<OrderDetails id={id} />
<div className="demo-dialog-actions">
<Dialog.Close className="demo-button" value="done" data-autofocus="">
Close
</Dialog.Close>
</div>
</Dialog.Popup>
</Dialog.Root>
</li>
))}
</ul>
<div className="demo-row">
<button type="button" className="demo-button" onClick={() => cache.clear()}>
Clear the cache
</button>
</div>
<output className="demo-readout">
last openChangeAction: <code>{last}</code>
</output>
</>
);
}/* Dialog: the Popup is the <dialog> itself. Open state is `:open` / `:modal` on it (exact in
both modes); the Trigger carries `data-open` / `data-pending` in library mode only. */
/* CSS-only scroll lock, keyed on :modal: covers dialogs opened before hydration and nested ones. */
html:has(.demo-dialog:modal) {
overflow: hidden;
}
.demo-dialog {
inline-size: min(28rem, calc(100vw - 32px));
padding: 20px 24px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
overscroll-behavior: contain;
}
.demo-dialog::backdrop {
background: rgb(0 0 0 / 0.45);
}
.demo-dialog h2 {
margin: 0 0 8px;
padding: 0;
border: 0;
font-size: 1.2rem;
}
.demo-dialog p {
margin: 0 0 12px;
color: var(--color-text-secondary);
}
/* Enter and exit with CSS, both modes. Enter works everywhere; the exit animates in Chromium
(with `overlay`) and Safari and is instant in Firefox. */
.demo-dialog,
.demo-dialog::backdrop {
opacity: 0;
transition:
opacity 150ms,
translate 150ms,
display 150ms allow-discrete,
overlay 150ms allow-discrete;
}
.demo-dialog {
translate: 0 8px;
}
.demo-dialog:open,
.demo-dialog:open::backdrop {
opacity: 1;
translate: 0 0;
}
@starting-style {
.demo-dialog:open,
.demo-dialog:open::backdrop {
opacity: 0;
}
.demo-dialog:open {
translate: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-dialog,
.demo-dialog::backdrop {
transition: none;
}
}
.demo-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.demo-dialog-form label {
display: grid;
gap: 4px;
}
.demo-dialog-form input {
padding: 0.35em 0.6em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
.demo-dialog-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
margin: 0;
}
.demo-dialog-details dd {
margin: 0;
}
/* The Trigger in library mode: optimistic open and pending. */
.demo-button[data-open] {
border-color: var(--color-primary);
color: var(--color-primary);
}
.demo-button[data-pending] {
cursor: progress;
}
ul.demo-list {
margin: 0 0 12px;
padding: 0;
list-style: none;
}
ul.demo-list .demo-row {
justify-content: space-between;
max-inline-size: 20rem;
padding-block: 4px;
}data-open and data-pending, and openChangeAction(true, { reason: "activate", returnValue: undefined }) runs.open renders the Popup's children; OrderDetails suspends, so React holds the commit. The dialog stays closed; the Trigger keeps spinning.showModal() runs in the same commit's layout effect, so the platform focuses fully rendered content (here the Close button, through initialFocus).lightDismiss) or the Close button: the browser closes it at once, then the Root receives openChangeAction(false, details) with reason: "dismiss" or "activate" and the returnValue. The second open is instant (the data is cached).To open now and show a skeleton instead, put a <Suspense> boundary inside the Popup: a newly mounted boundary shows its fallback immediately.
"use client";
import { startTransition, useState } from "react";
import { useFormStatus } from "react-dom";
import { Dialog } from "@funstack/ui/dialog";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { sleep } from "./fake-api.js";
function SaveButton() {
// React's form channel. The Form part only observes it; nothing in the library reports it as
// its own pending state.
const { pending } = useFormStatus();
return (
<button type="submit" className="demo-button demo-primary" value="save">
{pending && <span className="demo-spinner" aria-hidden />}
Save
</button>
);
}
// Dialog.Form with a function `action` renders <form action> (React's form action, passed
// through unwrapped, so a Server Function keeps progressive enhancement) and closes the dialog
// once the action's transition has settled, with the submitter's value as returnValue. A name
// containing "fail" makes the save reject: the Error Boundary inside the dialog shows the error
// and the dialog stays open.
export function DialogProfile() {
const [name, setName] = useState("Ada Lovelace");
return (
<>
<Dialog.Root defaultOpen={false}>
<Dialog.Trigger className="demo-button">Edit profile</Dialog.Trigger>
<Dialog.Popup className="demo-dialog" initialFocus="[name=name]">
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>
Type a name containing "fail" to see a rejected save.
</Dialog.Description>
<DemoErrorBoundary>
<Dialog.Form
className="demo-dialog-form"
action={async (formData) => {
const next = String(formData.get("name") ?? "");
await sleep(900);
if (next.toLowerCase().includes("fail")) {
throw new Error(`The server rejected the name "${next}".`);
}
// After an await: wrap the update yourself (React limitation).
startTransition(() => setName(next));
}}
>
<label>
Name <input name="name" defaultValue={name} />
</label>
<div className="demo-dialog-actions">
<Dialog.Close className="demo-button" value="cancel">
Cancel
</Dialog.Close>
<SaveButton />
</div>
</Dialog.Form>
</DemoErrorBoundary>
</Dialog.Popup>
</Dialog.Root>
<output className="demo-readout">
saved name: <strong>{name}</strong>
</output>
</>
);
}/* Dialog: the Popup is the <dialog> itself. Open state is `:open` / `:modal` on it (exact in
both modes); the Trigger carries `data-open` / `data-pending` in library mode only. */
/* CSS-only scroll lock, keyed on :modal: covers dialogs opened before hydration and nested ones. */
html:has(.demo-dialog:modal) {
overflow: hidden;
}
.demo-dialog {
inline-size: min(28rem, calc(100vw - 32px));
padding: 20px 24px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
overscroll-behavior: contain;
}
.demo-dialog::backdrop {
background: rgb(0 0 0 / 0.45);
}
.demo-dialog h2 {
margin: 0 0 8px;
padding: 0;
border: 0;
font-size: 1.2rem;
}
.demo-dialog p {
margin: 0 0 12px;
color: var(--color-text-secondary);
}
/* Enter and exit with CSS, both modes. Enter works everywhere; the exit animates in Chromium
(with `overlay`) and Safari and is instant in Firefox. */
.demo-dialog,
.demo-dialog::backdrop {
opacity: 0;
transition:
opacity 150ms,
translate 150ms,
display 150ms allow-discrete,
overlay 150ms allow-discrete;
}
.demo-dialog {
translate: 0 8px;
}
.demo-dialog:open,
.demo-dialog:open::backdrop {
opacity: 1;
translate: 0 0;
}
@starting-style {
.demo-dialog:open,
.demo-dialog:open::backdrop {
opacity: 0;
}
.demo-dialog:open {
translate: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-dialog,
.demo-dialog::backdrop {
transition: none;
}
}
.demo-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.demo-dialog-form label {
display: grid;
gap: 4px;
}
.demo-dialog-form input {
padding: 0.35em 0.6em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
.demo-dialog-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 16px;
margin: 0;
}
.demo-dialog-details dd {
margin: 0;
}
/* The Trigger in library mode: optimistic open and pending. */
.demo-button[data-open] {
border-color: var(--color-primary);
color: var(--color-primary);
}
.demo-button[data-pending] {
cursor: progress;
}
ul.demo-list {
margin: 0 0 12px;
padding: 0;
list-style: none;
}
ul.demo-list .demo-row {
justify-content: space-between;
max-inline-size: 20rem;
padding-block: 4px;
}useFormStatus().pending is true in the Save button. The dialog stays open.value ("save"), and the Root is notified with reason: "activate".For a single "run this, then close" button without a form, such as Delete, use Dialog.Close action: it shows data-pending on the button and closes when the action resolves.
autoFocus in server-rendered markup and Dialog.Form with a Server Function action (React's progressive enhancement). After hydration, and for content React renders on the client, use initialFocus: React does not write autofocus on client-rendered elements.openChangeAction(true, { reason: "platform" }) with no event; the content mounts into the open dialog. If the action rejects or a controlled parent refuses, the dialog closes again. A dialog whose pre-hydration experience matters belongs in JS-free mode.<button commandfor command="show-modal"> pointing at the dialog goes through the library too.Two independent channels, both usable from a Server Component:
// Persist "seen" when the user dismisses an announcement.
<Dialog.Root defaultOpen={!user.seenAnnouncement} openChangeAction={markAnnouncementSeen}>
...
</Dialog.Root>
// A form inside the dialog: progressive enhancement before hydration, close after success.
<Dialog.Form action={saveProfile}>...</Dialog.Form>openChangeAction is a per-dialog action with the Trigger's pending state; Dialog.Form action is React's form action with React's useFormStatus. The Form only observes the form channel to close the dialog afterwards; it never wraps your function and never reports form pending as the dialog's.
const [open, setOpen] = useState(false);
<Dialog.Root open={open} openChangeAction={(next) => { setOpen(next); }}>
<Dialog.Popup>...</Dialog.Popup>
</Dialog.Root><Dialog.Root
defaultOpen={false}
openChangeAction={(open, { reason, returnValue }) => {
if (!open && reason === "activate" && returnValue === "delete") deleteDraft();
}}
>