Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5e0563e
feat(ui): add UserButton account/org switcher
alexcarpenter Jul 16, 2026
c02ab84
feat(ui): give UserButton its own User section above Organization
alexcarpenter Jul 16, 2026
5180c54
refactor(ui): move UserButton into its own user-button/ folder
alexcarpenter Jul 16, 2026
8f7ffa2
fix(ui): resolve UserButton lint errors
alexcarpenter Jul 16, 2026
75bd1fd
refactor(ui): compose UserButton view from Mosaic Popover, Card, Item…
alexcarpenter Jul 31, 2026
c6e0c03
fix(ui): show additional account emails on md rows in UserButton
alexcarpenter Jul 31, 2026
7109b5f
fix(headless): only treat a menu as nested when it has a parent menu
alexcarpenter Jul 31, 2026
ec620bd
fix(ui): drop the popover positioner z-index
alexcarpenter Jul 31, 2026
f310bbe
feat(ui): render UserButton accounts as email rows with a menu
alexcarpenter Jul 31, 2026
99be01e
fix(headless): stop menu Escape from bubbling to the parent floating …
alexcarpenter Jul 31, 2026
dd026e6
fix(ui): make the UserButton trigger follow the avatar's shape
alexcarpenter Jul 31, 2026
338f356
feat(ui): give the Mosaic Popover trigger a themeable slot
alexcarpenter Jul 31, 2026
ddda67d
refactor(ui): name the Mosaic radius tokens by size
alexcarpenter Jul 31, 2026
12459d8
avatar disable dragging
alexcarpenter Jul 31, 2026
54a1e12
default item color to neutral faded
alexcarpenter Jul 31, 2026
2378021
Update item.styles.ts
alexcarpenter Jul 31, 2026
36f9c06
feat(headless): add alignOffset to Popover.Root
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 @@
---
---
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. 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
17 changes: 9 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
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
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
15 changes: 15 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ import {
} from '../stories/text.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
import { meta as useDataTableMeta } from '../stories/use-data-table.stories';
import {
Default as UserButtonDefault,
meta as userButtonMeta,
MultipleSessions as UserButtonMultipleAccounts,
Personal as UserButtonPersonal,
} from '../stories/user-button.stories';
Comment on lines +108 to +113

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

Preserve __source when constructing userButtonModule.

user-button.stories.tsx exports __source via its ?raw self-import, but this import list (Lines 108-113) omits it, and userButtonModule (Lines 185-190) never sets it. Any consumer of StoryModule.__source for the code-footer feature won't have it for UserButton.

This mirrors the same unresolved concern raised previously for accountButtonModule.

🛠️ Proposed fix
 import {
+  __source as userButtonSource,
   Default as UserButtonDefault,
   meta as userButtonMeta,
   MultipleSessions as UserButtonMultipleAccounts,
   Personal as UserButtonPersonal,
 } from '../stories/user-button.stories';
...
 const userButtonModule: StoryModule = {
   meta: userButtonMeta,
+  __source: userButtonSource,
   Default: UserButtonDefault,
   Personal: UserButtonPersonal,
   MultipleSessions: UserButtonMultipleAccounts,
 };

Also applies to: 185-190

🤖 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/swingset/src/lib/registry.ts` around lines 108 - 113, Update the
UserButton story imports to include the exported __source value, then assign it
to the __source property when constructing userButtonModule alongside the
existing story metadata. Preserve the current module construction behavior for
all other fields.

import { toSlug } from './slug';
import type { StoryModule } from './types';

Expand Down Expand Up @@ -176,6 +182,13 @@ const itemModule: StoryModule = {
Group: ItemGroup,
};

const userButtonModule: StoryModule = {
meta: userButtonMeta,
Default: UserButtonDefault,
Personal: UserButtonPersonal,
MultipleSessions: UserButtonMultipleAccounts,
};

const headingModule: StoryModule = {
meta: headingMeta,
Default: HeadingDefault,
Expand Down Expand Up @@ -216,6 +229,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta };
const useDataTableModule: StoryModule = { meta: useDataTableMeta };

export const registry: StoryModule[] = [
// User
userButtonModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
Expand Down
37 changes: 29 additions & 8 deletions packages/swingset/src/stories/popover.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,37 @@ centering on it.
Cross-axis flipping is only enabled for aligned placements, so `bottom-start` may become
`bottom-end` near a viewport edge while a plain `bottom` will not.

`alignOffset` nudges the popup along that alignment axis, the way `sideOffset` does along the side.
Use it to cancel padding inside the popup so its content, rather than its edge, lines up with the
trigger — a negative value pulls a `-start` placement further left.

```tsx
<Popover.Root
placement='bottom-start'
alignOffset={-8}
>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Popup aria-label='Account'>
<Card>
<Card.Content>Pulled 8px left of the trigger's start edge.</Card.Content>
</Card>
</Popover.Popup>
</Popover.Root>;
```

It is a preference like placement is: `shift` still claws the popup back when the nudge would push
it out of view.

## Parts

| Part | Slot | Description |
| --------------------- | --------------- | ----------------------------------------------------------------------- |
| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `modal`. |
| `Popover.Trigger` | | Anchor element; renders a `<button>` and accepts a `render` prop. |
| `Popover.Popup` | `popover-popup` | The floating box; owns `size`, viewport clamps, and the enter/exit run. |
| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |
| Part | Slot | Description |
| --------------------- | ----------------- | ----------------------------------------------------------------------------------- |
| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `alignOffset`, `modal`. |
| `Popover.Trigger` | `popover-trigger` | Anchor element; renders a `<button>` and accepts a `render` prop. |
| `Popover.Popup` | `popover-popup` | The floating box; owns `size`, viewport clamps, and the enter/exit run. |
| `Popover.Close` | — | Dismisses the popover; accepts a `render` prop. |
| `Popover.Title` | — | Heading; wired to the popup's `aria-labelledby`. |
| `Popover.Description` | — | Description; wired to the popup's `aria-describedby`. |

`Popover.Popup` renders the portal and the floating positioner internally. The positioner carries
the `popover-positioner` slot and is the `role="dialog"` element, which is why the popup's
Expand Down
114 changes: 114 additions & 0 deletions packages/swingset/src/stories/user-button.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as UserButtonStories from './user-button.stories';

# UserButton

The account & organization switcher that sits behind the user avatar. The active workspace sits at the
top with **Manage account / Sign out**, its organizations (including any **suggested** workspaces you
can Join) are listed under the account they belong to, and **additional accounts** follow. Clicking any
row switches to it; the account's own `⋯` menu carries its per-account actions. The foot of the surface
exposes **Add account** and **Sign out of all accounts**.

It is a composition of lower-layer Mosaic components — `Popover` for the floating surface, `Card` for
the surface itself, `Item` for every row, `Avatar` for the workspace marks, and `Menu` for the
per-account actions — so it inherits their behavior, focus management, and ARIA wiring rather than
restyling any of it. This first pass is presentational: the parts render from the props on
`UserButtonRoot` (mock data in the examples). A `useUserButtonController()` that reads live Clerk
resources is a drop-in follow-up.

## Example

<Story
name='Default'
storyModule={UserButtonStories}
/>

## Usage

The all-in-one `UserButton` renders the trigger and popup from a single prop-driven call:

```tsx
import { UserButton } from '@clerk/ui/mosaic/user-button/user-button.view';

<UserButton
status='ready'
activeSession={{ sessionId: 'sess_colin', userId: 'user_colin', name: 'Colin', email: 'colin@clerk.dev' }}
activeOrganizationId='org_clerk_app'
hasOrganizations
memberships={[
{ kind: 'membership', organizationId: 'org_clerk_app', name: 'Clerk app', membersCount: 24, planLabel: 'Pro plan', upgradeable: true },
{ kind: 'membership', organizationId: 'org_clerk_cloud', name: 'Clerk Cloud' },
]}
suggestions={[{ kind: 'suggestion', id: 'sug_labs', organizationId: 'org_clerk_labs', name: 'Clerk Labs', status: 'pending' }]}
invitations={[]}
additionalSessions={[{ sessionId: 'sess_braden', userId: 'user_braden', name: 'Braden', email: 'braden@clerk.dev' }]}
onSelectOrganization={id => setActive({ organization: id })}
onSelectPersonal={() => setActive({ organization: null })}
onSwitchSession={sessionId => setActive({ session: sessionId })}
onSignOutAll={() => signOut()}
/>
```

For layouts that need to drop the popup into their own trigger, compose the parts directly. The data
and callbacks live on `UserButtonRoot` and are read from context by the leaves, so they take no props:

```tsx
import {
UserButtonRoot,
UserButtonTrigger,
UserButtonPopup,
} from '@clerk/ui/mosaic/user-button/user-button.view';

<UserButtonRoot {...data} {...callbacks}>
<UserButtonTrigger />
<UserButtonPopup />
</UserButtonRoot>
```

The exports are flat (not `UserButton.Trigger`) so each part can declare its own `'use client'`
boundary without forcing the consumer's file to become a client component.

## States & scenarios

### Personal (no organizations)

When the active account has no organizations, the header collapses to a personal layout —
**Manage account / Sign out** — and the organization list is omitted.

<Story
name='Personal'
storyModule={UserButtonStories}
/>

### Personal & workspace

One account has workspaces (organizations); an additional account is a personal account (no orgs).
Switching to it flips the surface to the personal layout.

<Story
name='MultipleSessions'
storyModule={UserButtonStories}
/>

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Parts

| Part | Description |
| ------------------- | ---------------------------------------------------------------------------- |
| `UserButtonRoot` | Owns the data + callbacks and forwards popover open state to `Popover.Root`. |
| `UserButtonTrigger` | The trigger: the active workspace's avatar, and nothing else. |
| `UserButtonPopup` | The popover surface: header, workspace list, additional accounts, footer. |

## Styling

The component owns no slots of its own — it is styled entirely by the components it composes, so
`appearance.elements` targets those. Overriding `item` re-themes every row here _and_ everywhere else
`Item` is used; that is the point of composing rather than restyling.

| Component | Slots it contributes |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `Popover` | `popover-positioner`, `popover-popup` |
| `Card` | `card-root` |
| `Item` | `item`, `item-media`, `item-content`, `item-title`, `item-description`, `item-label`, `item-actions`, `item-group`, `item-separator` |
| `Avatar` | `avatar`, `avatar-image`, `avatar-fallback` |
| `Button` | `button` |
| `Icon` | `icon` |
| `Menu` | `menu-trigger`, `menu-positioner`, `menu-popup`, `menu-item`, `menu-separator` |
Loading
Loading