Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8d6b264
feat(ui): add UserButton account/org switcher
alexcarpenter Jul 16, 2026
db72f85
feat(ui): give UserButton its own User section above Organization
alexcarpenter Jul 16, 2026
f521b78
refactor(ui): move UserButton into its own user-button/ folder
alexcarpenter Jul 16, 2026
800fa07
fix(ui): resolve UserButton lint errors
alexcarpenter Jul 16, 2026
3766ea9
refactor(ui): compose UserButton view from Mosaic Popover, Card, Item…
alexcarpenter Jul 31, 2026
b7d1bd3
fix(ui): show additional account emails on md rows in UserButton
alexcarpenter Jul 31, 2026
b353c06
fix(headless): only treat a menu as nested when it has a parent menu
alexcarpenter Jul 31, 2026
a10e15a
fix(ui): drop the popover positioner z-index
alexcarpenter Jul 31, 2026
f2d770f
feat(ui): render UserButton accounts as email rows with a menu
alexcarpenter Jul 31, 2026
22a3020
fix(headless): stop menu Escape from bubbling to the parent floating …
alexcarpenter Jul 31, 2026
162ef8a
fix(ui): make the UserButton trigger follow the avatar's shape
alexcarpenter Jul 31, 2026
36266e6
feat(ui): give the Mosaic Popover trigger a themeable slot
alexcarpenter Jul 31, 2026
b1c773f
refactor(ui): name the Mosaic radius tokens by size
alexcarpenter Jul 31, 2026
b05d2f5
avatar disable dragging
alexcarpenter Jul 31, 2026
3eb023b
default item color to neutral faded
alexcarpenter Jul 31, 2026
4e1260b
Update item.styles.ts
alexcarpenter Jul 31, 2026
02fc6f5
feat(headless): add alignOffset to Popover.Root
alexcarpenter Jul 31, 2026
1714def
feat(ui): add UserButton mode prop for combined, orgs, and user switc…
alexcarpenter Jul 31, 2026
3ccad92
docs(ui): note UserButton mode in the changeset
alexcarpenter Jul 31, 2026
62cf21f
docs(repo): make the swingset UserButton examples live prototypes
alexcarpenter Jul 31, 2026
e5545ae
fix(ui): stop rendering the active UserButton workspace as a button
alexcarpenter Jul 31, 2026
abfc9ce
fix(ui): drop the separator above additional UserButton accounts
alexcarpenter Jul 31, 2026
1199d42
fix(ui): put every UserButton switch row in one group
alexcarpenter Jul 31, 2026
d8e02a5
fix(ui): keep the account at the head of the combined UserButton surface
alexcarpenter Jul 31, 2026
5ca30fb
feat(ui): list every account's organizations in the UserButton
alexcarpenter Jul 31, 2026
e7efb6a
fix(ui): tint Mosaic button icons per variant
alexcarpenter Jul 31, 2026
50cb374
fix(ui): give the Avatar fallback the image's radius
alexcarpenter Jul 31, 2026
fb3f75d
fix(ui): promote Item icons with the row, on interactive rows only
alexcarpenter Jul 31, 2026
febcc6e
docs(swingset): render Item docs icons with the Icon component
alexcarpenter Jul 31, 2026
4e052b8
style(ui): weight the UserButton account row email
alexcarpenter Jul 31, 2026
eccdae8
feat(ui): sign the UserButton footer with the Clerk logo
alexcarpenter Jul 31, 2026
59ee78c
style(ui): give the UserButton branding row more breathing room
alexcarpenter Jul 31, 2026
9656849
feat(headless): hold Popover contents while it closes (#9310)
alexcarpenter Jul 31, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-radius-scale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/popover-freeze-on-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/user-button-switcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

Add the `UserButton` Mosaic component: an account and organization switcher that combines multi-session account switching with organization selection, suggestions, and invitations behind a single popover. Exposes the all-in-one `UserButton` plus the composable `UserButtonRoot`, `UserButtonTrigger`, and `UserButtonPopup` parts. `mode` narrows what the surface carries: `combined` (the default) lists organizations and accounts together, `orgs` is an organization switcher with no account rows, and `user` is an account switcher that never shows an organization. It owns no slots of its own: it is composed from `Popover`, `Card`, `Item`, `Avatar` and `Menu`, so `appearance.elements` overrides for those components theme it too.
2 changes: 1 addition & 1 deletion packages/headless/src/primitives/menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall
- Nested menus open on hover (75ms delay) with a `safePolygon` safe zone.
- Only one sibling submenu can be open at a time.
- Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event.
- `Escape` closes the innermost menu first, bubbling up through the tree.
- `Escape` closes one level: the innermost open menu, leaving its parent — a parent menu, or a `Popover` the menu is rendered inside — open. Pressing it again closes the next level up. An outside press is the opposite: it dismisses the whole stack at once.

## Important Notes

Expand Down
9 changes: 7 additions & 2 deletions packages/headless/src/primitives/menu/menu-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ function MenuInner(props: MenuProps) {
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const parentId = useFloatingParentNodeId();
const isNested = parentId != null;
// A submenu, not merely a menu inside some other floating element. A menu rendered in a popover
// has a parent node id too, and treating that as nesting makes it hover-open, side-placed, and
// unclickable by mouse.
const isNested = parentId != null && parentContext != null;

const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);

Expand Down Expand Up @@ -98,7 +101,9 @@ function MenuInner(props: MenuProps) {
ignoreMouse: isNested,
});
const role = useRole(floatingContext, { role: 'menu' });
const dismiss = useDismiss(floatingContext, { bubbles: true });
// Escape must not bubble: it closes this menu and leaves whatever it sits inside — a parent menu,
// or a popover — open. An outside press is the opposite, and dismisses the whole stack.
const dismiss = useDismiss(floatingContext, { bubbles: { escapeKey: false, outsidePress: true } });
const listNavigation = useListNavigation(floatingContext, {
listRef: elementsRef,
activeIndex,
Expand Down
75 changes: 75 additions & 0 deletions packages/headless/src/primitives/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { axe } from '../../test-utils/axe';
import { Popover } from '../popover';
import { Menu } from './index';

afterEach(() => cleanup());
Expand Down Expand Up @@ -614,6 +615,80 @@ describe('Menu', () => {

expect(onClick).toHaveBeenCalledTimes(1);
});

it('Escape closes only the submenu', async () => {
const user = userEvent.setup();
render(
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Root>
<Menu.Trigger>Share</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Email'>Email</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>,
);

await user.click(screen.getByText('Actions'));
await new Promise(r => requestAnimationFrame(r));
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowRight}');
await user.keyboard('{Escape}');

expect(screen.getByText('Share')).toHaveAttribute('data-closed', '');
expect(screen.getByText('Actions')).toHaveAttribute('data-open', '');
});
});

describe('inside a popover', () => {
function renderMenuInPopover() {
return render(
<Popover.Root defaultOpen>
<Popover.Trigger>Open popover</Popover.Trigger>
<Popover.Positioner>
<Popover.Popup>
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Cut'>Cut</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>,
);
}

it('Escape closes only the menu', async () => {
const user = userEvent.setup();
renderMenuInPopover();

await user.click(screen.getByText('Actions'));
await user.keyboard('{Escape}');

expect(screen.getByText('Actions')).toHaveAttribute('data-closed', '');
expect(screen.getByText('Open popover')).toHaveAttribute('data-open', '');
});

it('Escape closes the popover once the menu is closed', async () => {
const user = userEvent.setup();
renderMenuInPopover();

await user.click(screen.getByText('Actions'));
await user.keyboard('{Escape}');
await user.keyboard('{Escape}');

expect(screen.getByText('Open popover')).toHaveAttribute('data-closed', '');
});
});

describe('positioner', () => {
Expand Down
18 changes: 10 additions & 8 deletions packages/headless/src/primitives/popover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ const [open, setOpen] = useState(false);

### `Popover.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 |
| `placement` | `Placement` | `"bottom"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
| `modal` | `boolean` | `false` | Traps focus within the popover |
| 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 |
| `placement` | `Placement` | `"bottom"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
| `alignOffset` | `number` | `0` | Nudge along the alignment axis (px) |
| `modal` | `boolean` | `false` | Traps focus within the popover |

### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close`

Expand Down Expand Up @@ -104,6 +105,7 @@ Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popu
- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent.
- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap.
- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically.
- **Popup contents freeze while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`@clerk/headless/utils`) and hold their last frame instead of re-rendering under the animation. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land. Freezing wraps the children in a `display: contents` element and detaches refs inside them until the popup reopens.

## ARIA

Expand Down
11 changes: 8 additions & 3 deletions packages/headless/src/primitives/popover/popover-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

import React from 'react';

import { type ComponentProps, mergeProps, useRender } from '../../utils';
import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils';
import { usePopoverContext } from './popover-context';

export type PopoverPopupProps = ComponentProps<'div'>;

export const PopoverPopup = React.forwardRef<HTMLDivElement, PopoverPopupProps>(function PopoverPopup(props, ref) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = usePopoverContext();
const { render, children, ...otherProps } = props;
const { open, popupRef, transitionProps } = usePopoverContext();

const defaultProps = {
...transitionProps,
// The popup outlives `open` by the length of its exit animation. Whatever closed it has
// usually changed the data behind it (switching account, picking an item), so the contents
// hold their last frame on the way out instead of swapping under the animation. The popup
// element itself stays live, so `data-closed` / `data-ending-style` still land.
children: <Freeze frozen={!open}>{children}</Freeze>,
};

return useRender({
Expand Down
5 changes: 3 additions & 2 deletions packages/headless/src/primitives/popover/popover-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ export interface PopoverProps {
onOpenChange?: (open: boolean) => void;
placement?: Placement;
sideOffset?: number;
alignOffset?: number;
modal?: boolean;
children: ReactNode;
}

function PopoverInner(props: PopoverProps) {
const nodeId = useFloatingNodeId();
const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props;
const { placement: placementProp = 'bottom', sideOffset = 4, alignOffset = 0, modal = false, children } = props;

const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);

Expand All @@ -61,7 +62,7 @@ function PopoverInner(props: PopoverProps) {
onOpenChange: setOpen,
placement: placementProp,
middleware: [
offset(sideOffset),
offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),
flip({
crossAxis: placementProp.includes('-'),
fallbackAxisSideDirection: 'end',
Expand Down
72 changes: 72 additions & 0 deletions packages/headless/src/utils/freeze.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { Freeze } from './freeze';

afterEach(() => {
cleanup();
});

describe('Freeze', () => {
it('renders children while not frozen', () => {
render(<Freeze frozen={false}>Acme</Freeze>);

expect(screen.getByText('Acme')).toBeInTheDocument();
});

it('holds the committed DOM when children change while frozen', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);

expect(screen.getByText('Acme')).toBeInTheDocument();
expect(screen.queryByText('Globex')).toBeNull();
});

it('keeps the held DOM visible', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);

expect(screen.getByText('Acme')).toBeVisible();
});

it('keeps the held DOM visible across further updates while frozen', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);
rerender(<Freeze frozen>Initech</Freeze>);

expect(screen.getByText('Acme')).toBeVisible();
});

it('commits the pending children once unfrozen', () => {
const { rerender } = render(<Freeze frozen={false}>Acme</Freeze>);

rerender(<Freeze frozen>Globex</Freeze>);
rerender(<Freeze frozen={false}>Globex</Freeze>);

expect(screen.getByText('Globex')).toBeInTheDocument();
expect(screen.queryByText('Acme')).toBeNull();
});

it('holds state updates raised from inside the frozen subtree', () => {
function Counter({ count }: { count: number }) {
return <span>count: {count}</span>;
}

const { rerender } = render(
<Freeze frozen={false}>
<Counter count={0} />
</Freeze>,
);

rerender(
<Freeze frozen>
<Counter count={1} />
</Freeze>,
);

expect(screen.getByText('count: 0')).toBeInTheDocument();
});
});
64 changes: 64 additions & 0 deletions packages/headless/src/utils/freeze.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client';

import * as React from 'react';

/**
* Never settles. Throwing it suspends the enclosing boundary indefinitely: React keeps
* rendering the subtree but holds the commit, so the DOM keeps painting its last frame.
*/
const never = new Promise<never>(() => {});

function Suspend(): null {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol, not an error. `React.use()` would say this more plainly but needs React 19.2; this package supports React 18.
throw never;
}

export interface FreezeProps {
/** While `true`, the DOM below holds whatever it last committed. */
frozen: boolean;
children?: React.ReactNode;
}

/**
* Holds its subtree's DOM at the last committed frame while `frozen`. Renders keep
* happening, they just don't reach the DOM; the pending one commits when `frozen` flips
* back to `false`.
*
* Use it to stop content from visibly changing under an exit animation — a popover that
* closes because the thing it was showing changed would otherwise swap its contents on the
* way out.
*/
export function Freeze({ frozen, children }: FreezeProps) {
const contentRef = React.useRef<HTMLDivElement | null>(null);

// Hold onto the node ourselves rather than reading a plain ref: hiding a boundary's children
// detaches their refs, so by the time the effect below runs a normal ref reads `null`.
const setContent = React.useCallback((node: HTMLDivElement | null) => {
if (node) {
contentRef.current = node;
}
}, []);

// React hides a suspended boundary's host children with `display: none !important`, which is
// the opposite of what this is for. Undo it on the commit that applies it: insertion effects
// run after the boundary's mutation and before paint, so the held frame never blinks out.
// `display: contents` is also what the wrapper renders with, so React puts it back on unfreeze
// and the wrapper stays out of the layout it is spliced into.
React.useInsertionEffect(() => {
if (frozen) {
contentRef.current?.style.setProperty('display', 'contents');
}
}, [frozen]);

return (
<React.Suspense fallback={null}>
{frozen ? <Suspend /> : null}
<div
ref={setContent}
style={{ display: 'contents' }}
>
{children}
</div>
</React.Suspense>
);
}
1 change: 1 addition & 0 deletions packages/headless/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { cssVars } from './css-vars';
export { Freeze, type FreezeProps } from './freeze';
export { resetLayoutStyles } from './reset-layout-styles';
export {
type ComponentProps,
Expand Down
3 changes: 3 additions & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
},
organization: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')),
Expand Down
Loading
Loading