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
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, and its slots are themeable via `appearance.elements`.
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 @@ -98,6 +98,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';
import { toSlug } from './slug';
import type { StoryModule } from './types';

Expand Down Expand Up @@ -162,6 +168,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 @@ -200,6 +213,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta };
const useDataTableModule: StoryModule = { meta: useDataTableMeta };

export const registry: StoryModule[] = [
// User
userButtonModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
Expand Down
115 changes: 115 additions & 0 deletions packages/swingset/src/stories/user-button.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as UserButtonStories from './user-button.stories';

# UserButton

The account & organization switcher that sits behind the user avatar. The active account sits at the
top with its organizations (including any **suggested** workspaces you can Join), and **additional
accounts** are listed below. Hovering the active account reveals **Sign out**; clicking any additional
account switches to it. **Settings / Members** open the combined profile modal, and the surface also
exposes **Add organization**, **Add account**, and **Sign out of all accounts**.

It is the styled Mosaic component composed from the headless `@clerk/headless` popover primitive plus
slot recipes, and inherits the primitive's open/close behavior, focus management, and ARIA wiring. 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_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@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_2', userId: 'user_2', name: 'Preston Booth', email: 'acme@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. The trigger still shows the
account avatar and name.

<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}
/>

## Parts

| Part | Description |
| ---------------------- | ---------------------------------------------------------------------------- |
| `UserButtonRoot` | Owns the data + callbacks and forwards popover open state to `Popover.Root`. |
| `UserButtonTrigger` | The sidebar trigger: active workspace avatar, name, plan badge, selector. |
| `UserButtonPopup` | The popover surface: header, workspace list, additional accounts, footer. |

## Styling

The component is themed with a Mosaic slot recipe (`userButtonRecipe`). Override any slot through
`appearance.elements` — e.g. `{ 'user-button-popup': { borderRadius: 24 } }`.

| Slot | Description |
| ------------------------------ | ------------------------------------------------------- |
| `user-button-trigger` | The trigger button |
| `user-button-popup` | The popover surface |
| `user-button-header` | Active workspace header |
| `user-button-action` | Header action buttons (Settings / Members) |
| `user-button-group` | A divided section (workspace list, additional accounts) |
| `user-button-item` | A selectable workspace / account row |
| `user-button-avatar` | Org (square) / account (circle) avatar |
| `user-button-inline-button` | Row actions (Join / Accept) |
| `user-button-hover-action` | The hover-revealed Sign out on the active account |
| `user-button-footer` | Sign out of all accounts + branding |
113 changes: 113 additions & 0 deletions packages/swingset/src/stories/user-button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/** @jsxImportSource @emotion/react */
import { UserButton, type UserButtonProps } from '@clerk/ui/mosaic/user-button/user-button.view';

import type { StoryMeta } from '@/lib/types';

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './user-button.stories?raw';

export const meta: StoryMeta = {
group: 'User',
title: 'UserButton',
source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx',
};
Comment on lines +10 to +14

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

Wire the stories into the playground knobs.

Set meta.styles to accountButtonRecipe, add the local knobsAsProps cast, and spread the resulting props into each AccountButton. The current stories discard every playground value.

As per path instructions, story functions must cast Record<string, unknown> through knobsAsProps, and Mosaic recipe metadata must be assigned to meta.styles.

Also applies to: 36-112

🤖 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/stories/account-button.stories.tsx` around lines 10 -
14, Update the AccountButton story metadata to assign accountButtonRecipe to
meta.styles. In each story function, cast the knobs Record<string, unknown>
through knobsAsProps and spread the resulting props into AccountButton so
playground values are applied.

Source: Path instructions


// The view is presentational, so the fixtures drive every state. All callbacks are wired as
// no-ops purely so each affordance renders (an unhandled action hides its control).
const handlers = {
onSelectOrganization: () => {},
onSelectPersonal: () => {},
onAcceptSuggestion: () => {},
onAcceptInvitation: () => {},
onSwitchSession: () => {},
onSignOutSession: () => {},
onSignOutAll: () => {},
onManageOrganization: () => {},
onManageMembers: () => {},
onManageAccount: () => {},
onCreateOrganization: () => {},
onAddAccount: () => {},
onUpgrade: () => {},
} satisfies Partial<UserButtonProps>;

const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' };

export function Default(_args: Record<string, unknown>) {
return (
<UserButton
{...handlers}
status='ready'
activeSession={preston}
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_2', userId: 'user_2', name: 'Preston Booth', email: 'acme@clerk.dev' }]}
/>
);
}

export function Personal(_args: Record<string, unknown>) {
return (
<UserButton
{...handlers}
status='ready'
activeSession={{
sessionId: 'sess_cam',
userId: 'user_cam',
name: 'Cameron Walker',
email: 'cameron.walker@gmail.com',
}}
activeOrganizationId={null}
hasOrganizations={false}
memberships={[]}
suggestions={[]}
invitations={[]}
additionalSessions={[
{ sessionId: 'sess_js', userId: 'user_js', name: 'Jeremy Sallee', email: 'jsallee@gmail.com' },
]}
/>
);
}

export function MultipleSessions(_args: Record<string, unknown>) {
return (
<UserButton
{...handlers}
status='ready'
activeSession={preston}
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={[]}
invitations={[]}
additionalSessions={[
{ sessionId: 'sess_cam', userId: 'user_cam', name: 'Cameron Walker', email: 'cameron.walker@gmail.com' },
]}
/>
);
}
48 changes: 48 additions & 0 deletions packages/ui/src/mosaic/icons/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,61 @@ const Close = glyph(
/>,
);

const ChevronUp = glyph(
<path
d='M4.75 10.25L8 6.75L11.25 10.25'
{...strokeProps}
/>,
);

const ChevronUpDown = glyph(
<path
d='M5.75 6.5L8 4L10.25 6.5M5.75 10L8 12.5L10.25 10'
{...strokeProps}
/>,
);

const Plus = glyph(
<path
d='M8 3.75V8M8 8V12.25M8 8H12.25M8 8L3.75 8'
{...strokeProps}
/>,
);

const Cog = glyph(
<path
d='M8 7.98999V7.99999M3.46012 4.84271L2.81628 5.88403C2.68081 6.10314 2.76504 6.38383 2.9599 6.55612C3.82824 7.32388 3.82825 8.67611 2.95992 9.44387C2.76507 9.61616 2.68084 9.89685 2.81631 10.116L3.46014 11.1573C3.59 11.3673 3.86848 11.4338 4.11036 11.3616C5.26915 11.0161 6.54871 11.678 6.8223 12.8011C6.88157 13.0444 7.08518 13.25 7.34377 13.25H8.65623C8.91482 13.25 9.11842 13.0444 9.1777 12.8011C9.45129 11.678 10.7308 11.0161 11.8896 11.3616C12.1315 11.4338 12.41 11.3673 12.5399 11.1573L13.1837 10.116C13.3192 9.89685 13.2349 9.61616 13.0401 9.44387C12.1717 8.67611 12.1718 7.32388 13.0401 6.55612C13.235 6.38383 13.3192 6.10314 13.1837 5.88403L12.5399 4.84271C12.41 4.63267 12.1315 4.56622 11.8897 4.63835C10.7309 4.98389 9.45129 4.32196 9.1777 3.19892C9.11842 2.95562 8.91482 2.75 8.65623 2.75H7.34377C7.08518 2.75 6.88157 2.95562 6.8223 3.19892C6.54871 4.32196 5.26913 4.98389 4.11033 4.63835C3.86845 4.56622 3.58997 4.63267 3.46012 4.84271Z'
{...strokeProps}
/>,
);

const Users = glyph(
<path
d='M10.4019 6C10.9101 5.69378 11.25 5.13658 11.25 4.5C11.25 3.86342 10.9101 3.30622 10.4019 3M9.5 13.25H12.4489C12.9612 13.25 13.3417 12.7993 13.2306 12.3242L13.0225 11.4345C12.8385 10.648 12.3786 9.97519 11.7524 9.49989M8.25 4.5C8.25 5.4665 7.4665 6.25 6.5 6.25C5.5335 6.25 4.75 5.4665 4.75 4.5C4.75 3.5335 5.5335 2.75 6.5 2.75C7.4665 2.75 8.25 3.5335 8.25 4.5ZM2.76939 12.3242L2.9775 11.4345C3.34439 9.86599 4.80874 8.75 6.5 8.75C8.19126 8.75 9.65561 9.86599 10.0225 11.4345L10.2306 12.3242C10.3417 12.7993 9.96121 13.25 9.44895 13.25H3.55105C3.03879 13.25 2.65827 12.7993 2.76939 12.3242Z'
{...strokeProps}
/>,
);

const SignOut = glyph(
<path
d='M10.7155 5.64655L13.25 8L10.7155 10.3534M13.069 8H7.09483M10.3534 2.75H4.19828C3.39841 2.75 2.75 3.39841 2.75 4.19828V11.8017C2.75 12.6016 3.39841 13.25 4.19828 13.25H10.3534'
{...strokeProps}
/>,
);

/** Runtime name → glyph map. `Icon`'s `name` prop is typed from these keys. */
export const iconRegistry = {
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'chevron-up-down': ChevronUpDown,
check: Check,
close: Close,
plus: Plus,
cog: Cog,
users: Users,
'sign-out': SignOut,
} satisfies Record<string, IconComponent>;

export type IconName = keyof typeof iconRegistry;
28 changes: 28 additions & 0 deletions packages/ui/src/mosaic/primitives/popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { PopoverPortalProps, PopoverProps } from '@clerk/headless/popover';
import { Popover as HeadlessPopover } from '@clerk/headless/popover';
import type { FunctionComponent } from 'react';

import { withMosaicSlot } from './withMosaicSlot';

/**
* The headless popover parts bridged into mosaic. Each styleable part is wrapped
* with `withMosaicSlot`, which forwards its ref and accepts the per-slot props a
* recipe produces (`css`, `data-cl-slot`, state attrs) — the bridged type is
* inferred, so there is nothing to hand-annotate per part.
*
* The structural parts (`Root`, `Portal`) render no element of their own and
* pass through unchanged; they are cast to their public component types so the
* inferred `Popover` type stays portable (otherwise it references internal
* `@clerk/headless` declaration paths).
*/
export const Popover = {
Root: HeadlessPopover.Root as FunctionComponent<PopoverProps>,
Trigger: withMosaicSlot(HeadlessPopover.Trigger),
Portal: HeadlessPopover.Portal as FunctionComponent<PopoverPortalProps>,
Positioner: withMosaicSlot(HeadlessPopover.Positioner),
Popup: withMosaicSlot(HeadlessPopover.Popup),
Arrow: withMosaicSlot(HeadlessPopover.Arrow),
Title: withMosaicSlot(HeadlessPopover.Title),
Description: withMosaicSlot(HeadlessPopover.Description),
Close: withMosaicSlot(HeadlessPopover.Close),
};
Loading
Loading