FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Dialog

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.

Delete this draft?

This cannot be undone.

Show code
DialogConfirm.tsx
// 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.css
/* 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.

Anatomy

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>
  • Root renders no DOM. Trigger and Popup need not be siblings: commandfor links them by id. A Popup without a Trigger is fine (controlled open); a Trigger without a Popup is a development warning.
  • Popup is the <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.

Two modes

JS-free modeLibrary mode
Selected whenRoot has none of open, defaultOpen, openChangeActionRoot has any of them
Openthe 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
Closethe browserthe browser; openChangeAction(false) is a notification
Popup contentalways mounted (in the server HTML)rendered from committed open; unmounted while closed unless keepMounted
Trigger data-open, data-pendingnot emitted; style :open on the dialogemitted (optimistic)
Before hydrationeverything worksthe native open happens; the content arrives on hydration (see below)
transition on Popupno 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).

Props

Dialog.Root

PropTypeDefaultDescription
openboolean–Controlled open state. undefined means uncontrolled. Selects library mode.
defaultOpenboolean–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.
openChangeActionChangeAction<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.
modalbooleantrueshowModal() (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.
childrenReactNode | ((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

PropTypeDefaultDescription
disabledbooleanfalseNative disabled: a disabled button is not an invoker and never opens.
classNamestring | ((state: DialogTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: DialogTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: DialogTriggerState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((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

PropTypeDefaultDescription
keepMountedbooleanfalseLibrary 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).
initialFocusRefObject<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).
lightDismissbooleanfalseClose 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_closedBybooleanfalseRenders 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.
transitionViewTransitionClass–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.
classNamestring | ((state: DialogPopupState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: DialogPopupState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: DialogPopupState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
actionAction<[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.
valuestring–Becomes dialog.returnValue, and details.returnValue of the close notification.
disabledbooleanfalseNative disabled.
classNamestring | ((state: DialogCloseState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: DialogCloseState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: DialogCloseState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
actionstring | ((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.
closeOnSettlebooleantrueWith 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.
methodstring–Only with a string action (for example "post"). Otherwise owned: "dialog" without action, React's own with a function.
classNamestring | ((state: DialogFormState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: DialogFormState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: DialogFormState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
classNamestring | ((state: DialogTitleState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: DialogTitleState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: DialogTitleState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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).

Data attributes and pseudo-classes

HookOnMeaning
:openPopupThe dialog is open (DOM truth, both modes).
:modalPopupOpened with showModal().
::backdropPopupThe modal backdrop.
data-modalPopupThe modal prop (also before the dialog opens).
data-openTriggerOptimistic open state (library mode only).
data-pendingTriggerAn open, or a close notification, is in flight (library mode only).
data-pendingCloseIts action is in flight.
data-disabledTrigger (library mode), CloseThe 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.

Keyboard and ARIA

KeyBehaviourWho
Enter / Space on the TriggerOpensthe browser (JS-free) or the Trigger
Tab / Shift+Tab in a modalCycles through the dialog and the browser UI; the page is inertthe 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 dialogCloses the innermost one onlythe browser
Enter in a form fieldSubmits the Form: closes, or runs the action and then closesthe browser / Form
  • The Popup's role is the native 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.
  • There is no focus trap: 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).
  • For long or destructive dialogs, focus a static element so the title is heard first: initialFocus pointing at a <Dialog.Title tabIndex={-1}>.
  • The Trigger is a plain button: no aria-expanded, no aria-haspopup. Give an icon-only Close an aria-label.

Escape cannot be turned off

Browsers honour cancel.preventDefault() only once per user activation, so a closeOnEscape={false} prop could not keep its promise. Build "unsaved changes" confirmation inside the dialog: a "Discard…" button that is the Trigger of a nested confirm Dialog, whose own Dialog.Close action does the work and then closes the outer dialog. Never write an action that awaits the user's answer in another dialog: React holds every transition started while an async action is pending, so the confirm would never open.

Async walkthrough (library mode)

  • Order #1042
  • Order #1043
last openChangeAction: (none yet)
Show code
DialogOrders.tsx
"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.css
/* 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;
}
  1. Click Details: the click is cancelled (so the native command does not run), the Trigger gets data-open and data-pending, and openChangeAction(true, { reason: "activate", returnValue: undefined }) runs.
  2. The commit that sets committed open renders the Popup's children; OrderDetails suspends, so React holds the commit. The dialog stays closed; the Trigger keeps spinning.
  3. The data arrives: the content commits and showModal() runs in the same commit's layout effect, so the platform focuses fully rendered content (here the Close button, through initialFocus).
  4. Close it with Escape, a click on the backdrop (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).
  5. If the opening action rejects, the error reaches the Error Boundary above the Root and the dialog never opens.

To open now and show a skeleton instead, put a <Suspense> boundary inside the Popup: a newly mounted boundary shows its fallback immediately.

Closing is not a veto

The dialog is already closed when openChangeAction(false) runs. A controlled parent that does not adopt false gets the dialog re-opened once the action settles: the user sees it close and come back. Adopt false unconditionally.

Forms: action, then close

saved name: Ada Lovelace
Show code
DialogProfile.tsx
"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 &quot;fail&quot; 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.css
/* 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;
}
  1. Submit: React runs the form action; useFormStatus().pending is true in the Save button. The dialog stays open.
  2. The action resolves and the name updates in a transition. When the form's pending state ends, the Form closes the dialog with the submitter's value ("save"), and the Root is notified with reason: "activate".
  3. A name containing "fail" rejects: the Error Boundary inside the Popup shows the error and the dialog stays open. A boundary above the Root would unmount the dialog instead.

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.

The platform paths

  • Before hydration, JS-free mode: everything works, including 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.
  • Before hydration, library mode: the Trigger already carries the command, so a click opens the dialog natively, but the content is not in the HTML yet. On hydration the Popup finds the dialog open and dispatches 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.
  • Foreign invokers: in library mode, any <button commandfor command="show-modal"> pointing at the dialog goes through the library too.

Server Functions

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.

Recipes

Controlled, opened without a Trigger

const [open, setOpen] = useState(false);

<Dialog.Root open={open} openChangeAction={(next) => { setOpen(next); }}>
  <Dialog.Popup>...</Dialog.Popup>
</Dialog.Root>

Branch on how it closed

<Dialog.Root
  defaultOpen={false}
  openChangeAction={(open, { reason, returnValue }) => {
    if (!open && reason === "activate" && returnValue === "delete") deleteDraft();
  }}
>

ViewTransition cost on open

A <ViewTransition> that mounts inside a transition makes React start a document view transition even with default="none", which runs the browser's root cross-fade and delays the next commit by about 250 ms. In a library-mode dialog that happens when the content mounts parts that render one (a nested Dialog, a Switch, a Checkbox, a RadioGroup item). Tracked in issue #35.