Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/olive-doors-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@clerk/headless': patch
'@clerk/ui': patch
Comment on lines +2 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve the removed Dialog APIs in this patch release.

This changeset states that consumers must replace sx and migrate existing size usage. That breaks consumers that remain on @clerk/ui@1 after a patch upgrade.

Keep deprecated compatibility paths for these APIs in a non-major release. Otherwise, publish this as a major release with a migration path.

As per coding guidelines, “Maintain backward compatibility in packages/clerk-js and packages/ui with SDK versions already in the wild.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/olive-doors-tell.md around lines 2 - 3, Update the Dialog changes
in the relevant UI and headless compatibility implementations to retain
deprecated support for the removed sx and existing size APIs, preserving
consumers on `@clerk/ui`@1 during this patch release. If compatibility cannot be
preserved, change the changeset entries for `@clerk/headless` and `@clerk/ui` from
patch to major and document the migration path.

Sources: Coding guidelines, Linked repositories

---

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 `<body>` 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.
12 changes: 12 additions & 0 deletions .changeset/spicy-clocks-argue.md
Original file line number Diff line number Diff line change
@@ -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<Payload>()`. 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.
124 changes: 112 additions & 12 deletions packages/headless/src/primitives/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,80 @@ const [open, setOpen] = useState(false);
<Dialog.Root modal={false}>{/* Focus is not trapped, page remains interactive */}</Dialog.Root>
```

### 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();

<Dialog.Trigger handle={feedbackDialog}>Give feedback</Dialog.Trigger>;

<Dialog.Root handle={feedbackDialog}>{/* ... */}</Dialog.Root>;
```

### 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<Payload>()`.

```tsx
const detail = Dialog.createHandle<{ name: string }>();

<Dialog.Trigger handle={detail} id='a' payload={{ name: 'Alice' }}>Alice</Dialog.Trigger>
<Dialog.Trigger handle={detail} id='b' payload={{ name: 'Bob' }}>Bob</Dialog.Trigger>

<Dialog.Root handle={detail}>
{({ payload }) => <Dialog.Popup>{payload?.name}</Dialog.Popup>}
</Dialog.Root>
```

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<string | null>(null);

<Dialog.Root
open={open}
triggerId={triggerId}
onOpenChange={(next, details) => {
setOpen(next);
setTriggerId(details.triggerId);
}}
>
{/* ... */}
</Dialog.Root>;
```

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:
Comment on lines +105 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline packages/headless/src/primitives/dialog --items all --match 'DialogFocusTarget|initialFocus|finalFocus'
rg -n -C 4 '\b(DialogFocusTarget|initialFocus|finalFocus)\b' packages/headless/src/primitives/dialog -g '*.ts' -g '*.tsx'

Repository: clerk/javascript

Length of output: 22452


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- README focus documentation ---'
cat -n packages/headless/src/primitives/dialog/README.md | sed -n '95,115p;190,210p'

printf '%s\n' '--- exported focus type and implementation ---'
cat -n packages/headless/src/primitives/dialog/dialog-popup.tsx | sed -n '10,32p;60,86p;86,116p'
cat -n packages/headless/src/primitives/dialog/index.ts | sed -n '1,25p'

printf '%s\n' '--- focus-related README references ---'
rg -n -C 3 'RefObject|callback|function|initialFocus|finalFocus|focus target' packages/headless/src/primitives/dialog/README.md

Repository: clerk/javascript

Length of output: 10340


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

source = Path("packages/headless/src/primitives/dialog/dialog-popup.tsx").read_text()
readme = Path("packages/headless/src/primitives/dialog/README.md").read_text()

type_match = re.search(
    r"export type DialogFocusTarget\s*=\s*(.*?);",
    source,
    re.S,
)
assert type_match, "DialogFocusTarget declaration not found"
type_text = " ".join(type_match.group(1).split())

callback_match = re.search(r"\(\(interactionType: InteractionType\) => ([^)]+)\)", type_text)
assert callback_match, "DialogFocusTarget callback return type not found"
callback_return = callback_match.group(1)

claim_match = re.search(
    r"Each accepts .*?a ref, or a function .*?returning any of those:",
    readme,
    re.S,
)
assert claim_match, "README focus-target claim not found"

print("DialogFocusTarget:", type_text)
print("Callback return type:", callback_return)
print("Callback return includes RefObject:", "RefObject" in callback_return)
print("README says callback returns the ref option:", "returning any of those" in claim_match.group(0))
PY

Repository: clerk/javascript

Length of output: 449


Correct the focus callback documentation.

DialogFocusTarget allows refs only as direct values. Callback results allow boolean, void, HTMLElement, or null. Remove refs from the callback description.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/dialog/README.md` around lines 105 - 108,
Update the initialFocus and finalFocus callback documentation for Dialog.Popup
to remove refs from the callback return options. Document callback results as
boolean, void, HTMLElement, or null, while keeping refs listed only as supported
direct values.

Source: Coding guidelines


```tsx
<Dialog.Popup
initialFocus={interactionType => (interactionType === 'keyboard' ? firstFieldRef.current : false)}
finalFocus={finalFocusRef}
>
{/* ... */}
</Dialog.Popup>
```

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 |
Expand All @@ -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`

Expand Down Expand Up @@ -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.

Expand All @@ -120,17 +214,23 @@ 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.

## Important Notes

- **`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
Expand Down
5 changes: 3 additions & 2 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export type DialogBackdropProps = ComponentProps<'div'>;
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
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,
Expand All @@ -28,6 +28,7 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
nested: (v: boolean): Record<string, string> | null => (v ? { 'data-nested': '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
Expand Down
31 changes: 30 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReferenceType>;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
popupRef: React.RefObject<HTMLDivElement | null>;
/** Where focus goes when the dialog closes, or `null` to leave focus alone. */
returnFocusRef: React.MutableRefObject<HTMLElement | null>;
/**
* 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;
Expand All @@ -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);
}
Loading
Loading