diff --git a/.changeset/olive-doors-tell.md b/.changeset/olive-doors-tell.md
new file mode 100644
index 00000000000..7e50597d7a6
--- /dev/null
+++ b/.changeset/olive-doors-tell.md
@@ -0,0 +1,28 @@
+---
+'@clerk/headless': patch
+'@clerk/ui': patch
+---
+
+Move the Mosaic `Dialog` onto StyleX, joining the other migrated components, and rework its sizing, motion and mobile behaviour.
+
+**Styling.** The dialog's rules now ship in `@clerk/ui/styles.css`. Style it by targeting the `.cl-dialog-backdrop` / `.cl-dialog-viewport` / `.cl-dialog-popup` slot classes from a CSS layer of your own, or per-part with `className` and `style`, in place of the previous `sx` prop.
+
+**Sizes.** `size` replaces `md` / `lg` with three named surfaces, and moves from `Dialog.Popup` to `Dialog.Root` because the backdrop reads it too. `prompt` (the default, `23.75rem`) asks one thing — a confirmation or a single-field form. `card` (`25rem`) is the sign-in / sign-up surface. `panel` fills the viewport minus its inset, up to `94rem` wide, so a settings surface does not resize as you navigate between its sections.
+
+**The inset.** The gap between a dialog and the edge of the screen is now a fixed inset that steps up at two breakpoints — `1rem`, `2rem` at `48rem`, `3rem` at `90rem` — rather than a percentage of the viewport. A percentage margin is asymmetric between the axes and the asymmetry tracks the viewport's aspect ratio, so the surround never read as an even frame.
+
+**Panels compose.** A `panel` clips rather than scrolling, and carries no padding of its own. Build the scroll region inside it with `scrollAreaRoot` / `scrollAreaViewport()`. That keeps anything anchored to the popup's corner from scrolling away, lets a scroll region sit flush with the dialog's edge, and makes a fixed-sidebar layout a plain flex row. `prompt` and `card` still pad themselves.
+
+**On a phone.** Below `48rem` a `prompt` pins to the bottom of the viewport and slides up as a sheet, keeping the inset on all four sides. `card` and `panel` are unchanged at every width. When an on-screen keyboard opens, `Dialog.Viewport` measures how much of the viewport it covers and pads for it, so a sheet rises to sit on top of the keyboard, a card re-centres in the space that is left without being squashed, and a panel shrinks.
+
+**Motion.** `prompt` and `card` scale out of the element that opened them — the dialog measures its trigger on open and exposes the result as `--cl-dialog-origin`, which the popup uses as its `transform-origin`; a dialog with no trigger falls back to a centred scale. Corner radius no longer distorts during the scale. `panel` has no enter or exit animation, since the absolute travel of a scale is a proportion of the element's own size. Under `prefers-reduced-motion: reduce` the movement drops and the fade remains.
+
+This also fixes the enter/exit transition, which was keyed to a `data-cl-starting-style` attribute the headless layer does not emit — dialogs previously appeared with no animation at all.
+
+**New `Dialog.CloseButton`.** The corner dismiss affordance: a ghost circular button holding the close glyph, anchored to the popup's top-inline-end corner. `Dialog.Close` is unchanged and stays unstyled, for footer "Cancel" buttons. Note that a close button rendered before a form becomes the dialog's initial focus.
+
+**Stacked dialogs.** A dialog opened from inside another one carries `data-nested` and paints a lighter scrim, so backdrops no longer compound into an opaque wall as the stack grows; the nested value is solved against the base so two levels composite to a `0.68` dim.
+
+**Browser chrome.** While a dialog is open, the mobile browser's own chrome is tinted to match the scrim — both `theme-color` and the `
` background, the latter being what paints the overscroll gutter and the area behind the address bar. On by default and needing no integration: the colour is derived from the backdrop rather than shipped, the meta is prepended rather than mutated so removing it restores the app's own, and it is refcounted across stacked dialogs. Opt out with `syncBrowserChrome={false}`.
+
+**`Button` gains an `xstyle` prop** for composing StyleX styles into its own, last so they win. Styles passed through `className` sit outside the button's `stylex.props` call and cannot be deduped, so the button's media-guarded rules — which compile to a doubled class — silently outrank them; positioning a button absolutely via `className` was ignored under a coarse pointer.
diff --git a/.changeset/spicy-clocks-argue.md b/.changeset/spicy-clocks-argue.md
new file mode 100644
index 00000000000..ee9c392f1f7
--- /dev/null
+++ b/.changeset/spicy-clocks-argue.md
@@ -0,0 +1,12 @@
+---
+'@clerk/headless': patch
+'@clerk/ui': patch
+---
+
+Add Base UI–style composition APIs to the Dialog, in both the headless primitive and the Mosaic component.
+
+**Detached triggers.** `Dialog.createHandle()` returns a handle; pass the same handle to a `Dialog.Trigger` and a `Dialog.Root`, and the trigger drives the dialog from anywhere in the tree — no JSX nesting required. The handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is mounted are ignored.
+
+**Multiple triggers and payloads.** Several triggers can share one dialog. Each can carry an `id` and a `payload`, and the root's children can be a function receiving `{ payload }` from the active trigger, so one dialog renders per-trigger content. Type the payload through the handle: `Dialog.createHandle()`. Everything keyed to "the trigger" now follows the one actually used: the dialog scales out of it and returns focus to it on close. In controlled mode, `triggerId` on `Dialog.Root` names the active trigger, and `onOpenChange` gains a second `details` argument (`{ trigger, triggerId, event }`) reporting the trigger behind each change — existing single-argument callbacks are unaffected. Setting `triggerId` alongside a programmatic `open` attributes the open to that trigger, which also gives controlled, trigger-less dialogs the origin-aware open animation.
+
+**Custom focus management.** `initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close. Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of the interaction type behind the change (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty when programmatic) returning any of those. Defaults are unchanged: first tabbable on open; the trigger on close, except after a pointer-driven dismissal, where focus stays where the pointer put it.
diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md
index 3ae2a9f1f76..dea9698cc18 100644
--- a/packages/headless/src/primitives/dialog/README.md
+++ b/packages/headless/src/primitives/dialog/README.md
@@ -45,6 +45,80 @@ const [open, setOpen] = useState(false);
{/* Focus is not trapped, page remains interactive */}
```
+### Detached triggers
+
+A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a handle;
+pass the same handle to both, and the trigger drives the root from anywhere in the tree. The
+handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is
+mounted are ignored.
+
+```tsx
+const feedbackDialog = Dialog.createHandle();
+
+Give feedback;
+
+{/* ... */};
+```
+
+### Multiple triggers and payloads
+
+Each trigger can carry an `id` and a `payload`. The root's children can be a function receiving
+the active trigger's payload, so one dialog renders per-trigger content. Type the payload through
+the handle: `Dialog.createHandle()`.
+
+```tsx
+const detail = Dialog.createHandle<{ name: string }>();
+
+Alice
+Bob
+
+
+ {({ payload }) => {payload?.name}}
+
+```
+
+In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second
+argument reports the trigger behind each change:
+
+```tsx
+const [open, setOpen] = useState(false);
+const [triggerId, setTriggerId] = useState(null);
+
+ {
+ setOpen(next);
+ setTriggerId(details.triggerId);
+ }}
+>
+ {/* ... */}
+;
+```
+
+Setting `triggerId` alongside a programmatic `open` also attributes the open to that trigger —
+the dialog scales out of it (`--cl-dialog-origin`) and returns focus to it on close, exactly as
+if it had been clicked.
+
+### Custom focus management
+
+`initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close.
+Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of
+the interaction type behind the open/close (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty
+for programmatic) returning any of those:
+
+```tsx
+ (interactionType === 'keyboard' ? firstFieldRef.current : false)}
+ finalFocus={finalFocusRef}
+>
+ {/* ... */}
+
+```
+
+The defaults stay what they were: first tabbable element on open; on close, the trigger — unless
+the close was pointer-driven, where focus is left where the pointer put it (see `useReturnFocus`).
+
## Parts
| Part | Default Element | Description |
@@ -63,13 +137,16 @@ const [open, setOpen] = useState(false);
### `Dialog.Root`
-| Prop | Type | Default | Description |
-| -------------- | ----------------------------------- | ------- | --------------------------------------- |
-| `open` | `boolean` | — | Controlled open state |
-| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
-| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
-| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
-| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
+| Prop | Type | Default | Description |
+| -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------- |
+| `open` | `boolean` | — | Controlled open state |
+| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
+| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` | — | Called when open state changes; `details` names the trigger behind it |
+| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
+| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
+| `handle` | `DialogHandle` | — | Connects detached triggers (see `Dialog.createHandle()`) |
+| `triggerId` | `string \| null` | — | Controls which trigger the open is attributed to |
+| `children` | `ReactNode \| ({ payload }) => ReactNode` | — | Content, or a render function of the active trigger's `payload` |
#### `closedBy`
@@ -107,7 +184,24 @@ When `root` is provided, the dialog is portaled into that container instead of `
| ------------ | --------- | ------- | ------------------------------- |
| `lockScroll` | `boolean` | `true` | Prevents body scroll while open |
-### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
+### `Dialog.Trigger`
+
+| Prop | Type | Default | Description |
+| --------- | -------------- | ------- | -------------------------------------------------------- |
+| `handle` | `DialogHandle` | — | Drives a root elsewhere in the tree (detached trigger) |
+| `id` | `string` | auto | Names this trigger for the root's `triggerId` |
+| `payload` | `Payload` | — | Delivered to the root's children render function on open |
+
+### `Dialog.Popup`
+
+| Prop | Type | Default | Description |
+| -------------- | ------------------- | ------- | --------------------------------------- |
+| `initialFocus` | `DialogFocusTarget` | `true` | Where focus moves when the dialog opens |
+| `finalFocus` | `DialogFocusTarget` | `true` | Where focus returns when it closes |
+
+`DialogFocusTarget` is `boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null`.
+
+### `Dialog.Backdrop`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close`
No additional props beyond standard HTML attributes and the `render` prop.
@@ -120,9 +214,14 @@ No additional props beyond standard HTML attributes and the `render` prop.
## Data Attributes
-| Attribute | Applies To | Description |
-| --------------------------- | ---------------------------------- | ----------- |
-| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
+| Attribute | Applies To | Description |
+| --------------------------- | ---------------------------------- | ------------------------------------------- |
+| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
+| `data-nested` | Backdrop, Viewport, Popup | Opened from inside another floating element |
+
+`data-nested` is what a stacked overlay styles itself from — chiefly so backdrops don't composite
+into an ever-darker scrim as the stack grows. It reflects any floating ancestor, not strictly a
+dialog one: the `FloatingTree` a Menu or Popover establishes counts too.
The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above.
@@ -130,7 +229,8 @@ The headless parts are unstyled. Target a part with your own className (or `rend
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
-- **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically.
+- **Nested dialogs are supported**, and covered by tests. The `FloatingTree` pattern handles it: `useDismiss` blocks both Escape and outside-press on a parent while any child is open, and `FloatingOverlay`'s scroll lock is refcounted, so the body stays locked until the last dialog closes.
+- **`Dialog.Popup` gets a `--cl-dialog-origin` custom property** when the dialog was opened from a `Dialog.Trigger` — the trigger's centre, in the popup's own coordinate space. Use it as `transform-origin` to scale the dialog out of whatever opened it. It is left unset for a dialog with no trigger, so a `var(--cl-dialog-origin, center)` fallback centres the scale.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.
## Authoring rule for new primitives
diff --git a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx
index 00aee9a2431..c934d4bfe6d 100644
--- a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx
+++ b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx
@@ -12,9 +12,9 @@ export type DialogBackdropProps = ComponentProps<'div'>;
export const DialogBackdrop = React.forwardRef(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
- const { open, mounted, transitionProps } = useDialogContext();
+ const { open, mounted, isNested, transitionProps } = useDialogContext();
- const state = { open };
+ const state = { open, nested: isNested };
const defaultProps = {
...transitionProps,
@@ -28,6 +28,7 @@ export const DialogBackdrop = React.forwardRef | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
+ nested: (v: boolean): Record | null => (v ? { 'data-nested': '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts
index b730f698d36..d4f2775036a 100644
--- a/packages/headless/src/primitives/dialog/dialog-context.ts
+++ b/packages/headless/src/primitives/dialog/dialog-context.ts
@@ -2,18 +2,42 @@ import type { ExtendedRefs, FloatingContext, ReferenceType, UseInteractionsRetur
import { createContext, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';
+import type { DialogHandle } from './dialog-handle';
export interface DialogContextValue {
open: boolean;
setOpen: (open: boolean) => void;
floatingContext: FloatingContext;
refs: ExtendedRefs;
- getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
popupRef: React.RefObject;
/** Where focus goes when the dialog closes, or `null` to leave focus alone. */
returnFocusRef: React.MutableRefObject;
+ /**
+ * The store connecting this root to its triggers — the `handle` prop when one was passed,
+ * otherwise a private store the root created. Triggers nested inside the root reach it here;
+ * detached triggers hold the same object through their `handle` prop.
+ */
+ store: DialogHandle;
+ /**
+ * Set by `Dialog.Popup` when its `finalFocus` is a function, and invoked by the root
+ * synchronously on every close — dismissal or programmatic — with the event behind it, if
+ * any. Resolving inside the close call is what guarantees the result is in place before
+ * `FloatingFocusManager` restores focus; an effect can lose that race when close and unmount
+ * land in the same commit.
+ */
+ finalFocusResolverRef: React.MutableRefObject<((event: Event | undefined) => void) | null>;
modal: boolean;
+ /**
+ * Whether this dialog opened from inside another floating element, so a stacked overlay can
+ * style itself differently from the one beneath it — chiefly so backdrops don't composite into
+ * an ever-darker scrim as the stack grows.
+ *
+ * True for any floating ancestor, not strictly a dialog one: the `FloatingTree` a Menu or
+ * Popover establishes counts too. That is the honest reading of what is knowable here, and the
+ * cases coincide in practice.
+ */
+ isNested: boolean;
labelId: string;
descriptionId: string;
mounted: boolean;
@@ -29,3 +53,8 @@ export function useDialogContext() {
}
return ctx;
}
+
+/** Context access for parts that can also live outside the root — a trigger given a `handle`. */
+export function useOptionalDialogContext() {
+ return useContext(DialogContext);
+}
diff --git a/packages/headless/src/primitives/dialog/dialog-handle.ts b/packages/headless/src/primitives/dialog/dialog-handle.ts
new file mode 100644
index 00000000000..9524da4a11b
--- /dev/null
+++ b/packages/headless/src/primitives/dialog/dialog-handle.ts
@@ -0,0 +1,141 @@
+/**
+ * A handle connects `Dialog.Trigger` and `Dialog.Root` without JSX nesting, mirroring Base UI's
+ * `Dialog.createHandle()`: create one at module scope (or in state), pass it to both, and a
+ * trigger anywhere in the tree drives a root it is not nested under.
+ *
+ * The same store also backs in-context triggers — a root with no `handle` prop creates a private
+ * one — so nested and detached triggers share a single registration and open/close path.
+ */
+
+/** How the trigger that opened (or last opened) the dialog is known to the root. */
+export interface DialogTriggerRegistration {
+ id: string;
+ element: HTMLElement;
+ payload: Payload | undefined;
+}
+
+/**
+ * What the root exposes to triggers through the handle. Present only while a root is mounted;
+ * requests made with no root attached are ignored, matching Base UI.
+ * @internal
+ */
+export interface DialogRootController {
+ openFromTrigger: (id: string, event: Event) => void;
+ closeFromTrigger: (id: string, event: Event) => void;
+ setOpen: (open: boolean) => void;
+}
+
+/** The slice of root state a trigger renders from: its `data-open` / ARIA wiring. */
+export interface DialogHandleState {
+ open: boolean;
+ /** The id of the trigger the open is attributed to, or `null` when none is named. */
+ triggerId: string | null;
+ /** The popup's DOM id while open, for the trigger's `aria-controls`. */
+ popupId: string | undefined;
+}
+
+const CLOSED_STATE: DialogHandleState = { open: false, triggerId: null, popupId: undefined };
+
+/**
+ * Links triggers to a dialog root without requiring them to be nested inside it.
+ * Create with {@link createDialogHandle}; every member is internal wiring.
+ *
+ * Members use method syntax deliberately: methods are bivariant in their parameters, which
+ * lets a `DialogHandle` flow into contexts typed `DialogHandle`.
+ */
+export interface DialogHandle {
+ /** Opens the attached root. Ignored while no root is mounted. */
+ open(): void;
+ /** Closes the attached root. Ignored while no root is mounted. */
+ close(): void;
+ /** Whether the attached root is open. `false` while no root is mounted. */
+ readonly isOpen: boolean;
+ /** @internal */
+ registerTrigger(registration: DialogTriggerRegistration): () => void;
+ /** @internal */
+ getTrigger(id: string): DialogTriggerRegistration | undefined;
+ /** @internal */
+ getFirstTrigger(): DialogTriggerRegistration | undefined;
+ /** @internal Bumps whenever the trigger registry changes; lets the root re-resolve its reference element. */
+ getRegistryVersion(): number;
+ /** @internal */
+ setRoot(controller: DialogRootController): () => void;
+ /** @internal */
+ requestOpen(id: string, event: Event): void;
+ /** @internal */
+ requestClose(id: string, event: Event): void;
+ /** @internal */
+ publishState(state: DialogHandleState): void;
+ /** @internal */
+ getState(): DialogHandleState;
+ /** @internal */
+ subscribe(listener: () => void): () => void;
+}
+
+/**
+ * Creates a {@link DialogHandle} to pass to both a `Dialog.Trigger` and a `Dialog.Root`, so a
+ * detached trigger can drive the dialog. The type parameter types the `payload` carried from
+ * each trigger into the root's children render function.
+ */
+export function createDialogHandle(): DialogHandle {
+ const triggers = new Map>();
+ const listeners = new Set<() => void>();
+ let root: DialogRootController | null = null;
+ let state = CLOSED_STATE;
+ let registryVersion = 0;
+
+ const notify = () => listeners.forEach(listener => listener());
+
+ return {
+ open() {
+ root?.setOpen(true);
+ },
+ close() {
+ root?.setOpen(false);
+ },
+ get isOpen() {
+ return state.open;
+ },
+ registerTrigger(registration) {
+ triggers.set(registration.id, registration);
+ registryVersion++;
+ notify();
+ return () => {
+ if (triggers.get(registration.id) === registration) {
+ triggers.delete(registration.id);
+ registryVersion++;
+ notify();
+ }
+ };
+ },
+ getTrigger: id => triggers.get(id),
+ getFirstTrigger: () => triggers.values().next().value,
+ getRegistryVersion: () => registryVersion,
+ setRoot(controller) {
+ root = controller;
+ return () => {
+ if (root === controller) {
+ root = null;
+ }
+ };
+ },
+ requestOpen(id, event) {
+ root?.openFromTrigger(id, event);
+ },
+ requestClose(id, event) {
+ root?.closeFromTrigger(id, event);
+ },
+ publishState(next) {
+ if (next.open === state.open && next.triggerId === state.triggerId && next.popupId === state.popupId) {
+ return;
+ }
+ state = next;
+ notify();
+ },
+ getState: () => state,
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ };
+}
diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx
index 98a1d706987..62c3db40bdb 100644
--- a/packages/headless/src/primitives/dialog/dialog-popup.tsx
+++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx
@@ -4,33 +4,127 @@ import { FloatingFocusManager } from '@floating-ui/react';
import React from 'react';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
+import { type InteractionType, interactionTypeFromEvent } from '../../utils/interaction-modality';
import { useDialogContext } from './dialog-context';
+import { useDialogOrigin } from './use-dialog-origin';
+
+/**
+ * Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`),
+ * mirroring Base UI:
+ *
+ * - `true` or omitted — the default: first tabbable element on open, the trigger (with the
+ * pointer-close downgrade `useReturnFocus` applies) on close
+ * - `false` — do not move focus
+ * - a ref — focus that element
+ * - a function of the interaction type behind the open/close (`''` when programmatic) —
+ * returns any of the above, with `void`/`null` meaning the default
+ */
+export type DialogFocusTarget =
+ | boolean
+ | React.RefObject
+ | ((interactionType: InteractionType) => boolean | void | HTMLElement | null);
/** Props for {@link DialogPopup}. */
-export type DialogPopupProps = ComponentProps<'div'>;
+export interface DialogPopupProps extends ComponentProps<'div'> {
+ /** Where focus moves when the dialog opens. Default: the first tabbable element inside it. */
+ initialFocus?: DialogFocusTarget;
+ /** Where focus returns when the dialog closes. Default: the trigger, via `useReturnFocus`. */
+ finalFocus?: DialogFocusTarget;
+}
/** The dialog content container. Manages focus trapping via `FloatingFocusManager` and wires ARIA attributes from `Dialog.Title` and `Dialog.Description`. */
export const DialogPopup = React.forwardRef(function DialogPopup(props, ref) {
- const { render, ...otherProps } = props;
+ const { render, initialFocus, finalFocus, ...otherProps } = props;
const {
+ open,
popupRef,
refs,
getFloatingProps,
floatingContext,
modal,
+ isNested,
returnFocusRef,
+ finalFocusResolverRef,
labelId,
descriptionId,
mounted,
transitionProps,
} = useDialogContext();
+ // Measured here rather than on the root: `Dialog.Portal` renders through `FloatingPortal`,
+ // which creates its container in a layout effect and renders nothing until it exists. A root
+ // effect keyed on `open` would therefore run one commit before the popup is in the DOM and
+ // never re-run. This component only renders once the portal is up, so its own layout effect
+ // is the first moment the popup can be measured.
+ useDialogOrigin(popupRef, floatingContext.elements.domReference, open);
+
+ // Resolved at render, into the `number | ref` form `FloatingFocusManager` takes (a negative
+ // index disables the focus move). The function form reads the open event floating-ui has
+ // already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it.
+ const initialFocusElementRef = React.useRef(null);
+ const resolvedInitialFocus = React.useMemo((): number | React.MutableRefObject => {
+ if (!open || initialFocus === undefined || initialFocus === true) {
+ return 0;
+ }
+ if (initialFocus === false) {
+ return -1;
+ }
+ if (typeof initialFocus !== 'function') {
+ return initialFocus as React.MutableRefObject;
+ }
+ const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent));
+ if (result === false) {
+ return -1;
+ }
+ if (result instanceof HTMLElement) {
+ initialFocusElementRef.current = result;
+ return initialFocusElementRef;
+ }
+ return 0;
+ }, [open, initialFocus, floatingContext]);
+
+ // The function form of `finalFocus` resolves inside the root's close call — synchronously,
+ // before any teardown — into this ref, which is what the focus manager then restores to.
+ const resolvedFinalFocusRef = React.useRef(null);
+ const finalFocusLatestRef = React.useRef(finalFocus);
+ React.useLayoutEffect(() => {
+ finalFocusLatestRef.current = finalFocus;
+ });
+ React.useLayoutEffect(() => {
+ finalFocusResolverRef.current = event => {
+ const target = finalFocusLatestRef.current;
+ if (typeof target !== 'function') {
+ return;
+ }
+ const result = target(interactionTypeFromEvent(event));
+ resolvedFinalFocusRef.current =
+ result instanceof HTMLElement ? result : result === false ? null : returnFocusRef.current;
+ };
+ return () => {
+ finalFocusResolverRef.current = null;
+ };
+ }, [finalFocusResolverRef, returnFocusRef]);
+
+ const resolvedReturnFocus =
+ finalFocus === undefined || finalFocus === true
+ ? returnFocusRef
+ : finalFocus === false
+ ? false
+ : typeof finalFocus === 'function'
+ ? resolvedFinalFocusRef
+ : (finalFocus as React.MutableRefObject);
+
const ownProps = {
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
} satisfies DefaultProps<'div'>;
- const defaultProps = { ...ownProps, ...getFloatingProps(), ...transitionProps };
+ const defaultProps = {
+ ...ownProps,
+ ...(isNested ? { 'data-nested': '' } : {}),
+ ...getFloatingProps(),
+ ...transitionProps,
+ };
const element = useRender({
defaultTagName: 'div',
@@ -53,7 +147,8 @@ export const DialogPopup = React.forwardRef(fu
context={floatingContext}
modal={modal}
outsideElementsInert={modal}
- returnFocus={returnFocusRef}
+ initialFocus={resolvedInitialFocus}
+ returnFocus={resolvedReturnFocus}
>
{element}
diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx
index 4b5ba8bdaa9..a78a50a014f 100644
--- a/packages/headless/src/primitives/dialog/dialog-root.tsx
+++ b/packages/headless/src/primitives/dialog/dialog-root.tsx
@@ -3,7 +3,6 @@
import {
FloatingNode,
FloatingTree,
- useClick,
useDismiss,
useFloating,
useFloatingNodeId,
@@ -11,12 +10,13 @@ import {
useInteractions,
useRole,
} from '@floating-ui/react';
-import { type ReactNode, useId, useMemo, useRef } from 'react';
+import { type ReactNode, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useControllableState } from '../../hooks/use-controllable-state';
import { useReturnFocus } from '../../hooks/use-return-focus';
import { useTransition } from '../../hooks/use-transition';
import { DialogContext, type DialogContextValue } from './dialog-context';
+import { createDialogHandle, type DialogHandle } from './dialog-handle';
/**
* Which gestures dismiss the dialog, mirroring the native `
```
-The `trigger` render prop receives the interaction props (ARIA attributes, click handler) from
-Floating UI and should spread them onto whatever element opens the dialog.
+The `trigger` render prop receives the interaction props (ARIA attributes, click handler) and
+spreads them onto whatever element opens the dialog. It is optional — omit it for a dialog driven
+entirely by `open`, opened from a menu item, a route, or a state machine.
-`children` can also be a plain `ReactNode` when no programmatic close is needed — the dialog
-can always be dismissed via Escape or clicking the backdrop:
+`children` can also be a plain `ReactNode` when no programmatic close is needed — the dialog can
+always be dismissed via Escape or the backdrop:
```tsx
```
-## Sub-parts
+### Size
-| Part | Slot | Description |
-| -------------------- | -------------------- | ---------------------------------------------------- |
-| `Dialog.Title` | `dialog-title` | Heading; wired to the popup's `aria-labelledby` |
-| `Dialog.Description` | `dialog-description` | Description; wired to the popup's `aria-describedby` |
+`size` names the surface, not a t-shirt step:
-## Styled slots
+| Value | Size | For |
+| -------- | -------------------------------------------- | ---------------------------------------------------------------------- |
+| `prompt` | `max-width: 23.75rem`, height from content | Asking one thing: a confirmation, or a single-field form (the default) |
+| `card` | `max-width: 25rem`, height from content | The sign-in / sign-up surface |
+| `panel` | `max-width: 94rem`, fills the viewport inset | The account-profile and settings surface, which you navigate |
-The Mosaic dialog exposes the following slots that can be styled via `appearance.elements`:
+`prompt` and `card` set a max width and let their content decide the height. `panel` fixes both
+axes: its content navigates in place — a settings surface switching sections — so a content-driven
+height would resize the window on every section change.
-| Slot | Component | Description |
-| ----------------- | --------- | --------------------------------------------------- |
-| `dialog-backdrop` | Backdrop | Themed overlay behind the dialog |
-| `dialog-viewport` | Viewport | Fixed centering container; owns scroll lock |
-| `dialog-popup` | Popup | The dialog surface (`role="dialog"`, focus-trapped) |
+`size` lives on `Dialog.Root`, not `Dialog.Popup`, because the backdrop reads it too.
+
+### The inset
+
+The gap between a dialog and the edge of the screen is a fixed inset, even on all four sides, that
+steps up at two breakpoints:
+
+| Viewport | Inset |
+| ------------- | ------------- |
+| `< 48rem` | `1rem` (16px) |
+| `48rem–90rem` | `2rem` (32px) |
+| `>= 90rem` | `3rem` (48px) |
+
+It lives on `Dialog.Viewport`'s padding: a `prompt` or `card` gets it for free by being
+`width: 100%` inside it, and `panel` reads the same value back for its height.
+
+### On a phone, a prompt is a sheet
+
+Below `48rem`, a `prompt` pins to the bottom of the viewport and slides up instead of scaling out
+of its trigger. It keeps the inset on all four sides and all four corners rounded — a floating
+sheet, not a tray welded to the edge — and its width cap lifts so it spans whatever the inset
+leaves. `card` and `panel` are unchanged at every width. Resize the preview under
+[Playground](#playground) below `48rem` to see it.
+
+The sheet slides in fully opaque, and the backdrop keeps its own faster timing — the scrim answers
+the tap first, then the sheet arrives into an already-dimmed page. Under
+`prefers-reduced-motion: reduce` the sheet holds flat and fades instead.
+
+Drag-to-dismiss is deliberately absent — `Drawer` owns the drag engine, and a second one should not
+grow inside `Dialog`.
+
+### Close button
+
+`Dialog.CloseButton` is the corner X — a ghost circular `Button` holding the close glyph, anchored
+to the popup's top-inline-end corner. Being absolutely positioned, it never joins the popup's
+column layout, so you can render it anywhere among the children without the rest moving.
+
+```tsx
+
+```
+
+It carries an English `Close` label by default; pass `aria-label` to override it.
+
+`Dialog.Close` stays available and unstyled — that is what a "Cancel" button in a footer wants.
+`Dialog.CloseButton` is the styled corner affordance.
+
+> **Where you put it decides what the dialog opens focused on.** Focus goes to the first tabbable
+> element, so a `Dialog.CloseButton` rendered before the form makes "dismiss" the initial focus.
+> Point `initialFocus` on `Dialog.Popup` at the field that should take it instead — see
+> [Custom focus management](#custom-focus-management).
+
+### Dismissal
+
+`closedBy` chooses which gestures dismiss the dialog, mirroring the native `