Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-menu.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add an @clerk/ui release entry.

This empty changeset will omit the new public Menu API from the package release and changelog. Add an @clerk/ui minor entry with a concise feature summary.

Proposed fix
 ---
+'`@clerk/ui`': minor
---
++Add the Mosaic Menu component and menu glyphs.

As per coding guidelines, use Changesets for version management and changelogs. Based on learnings, empty changesets are only appropriate for documentation-only or non-published changes.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
---
---
'`@clerk/ui`': minor
---
Add the Mosaic Menu component and menu glyphs.
🤖 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/mosaic-menu.md around lines 1 - 2, Replace the empty changeset
front matter in mosaic-menu.md with an `@clerk/ui` minor release entry and add a
concise summary describing the new public Menu API.

Sources: Coding guidelines, Learnings

2 changes: 2 additions & 0 deletions .changeset/tidy-menus-wave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
text: dynamic(() => import('../stories/text.mdx')),
},
Expand Down
4 changes: 4 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,7 @@ import {
Interactive as ItemInteractive,
meta as itemMeta,
} from '../stories/item.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
import {
Default as OrganizationProfileDefault,
Expand DownExpand Up@@ -169,6 +170,8 @@ const headingModule: StoryModule = {
Colors: HeadingColors,
};

const menuComponentModule: StoryModule = { meta: menuComponentMeta, Default: MenuComponentDefault };

const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: TabsComponentDefault };

const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Colors: TextColors };
Expand DownExpand Up@@ -221,6 +224,7 @@ export const registry: StoryModule[] = [
dialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
tabsComponentModule,
textModule,
// Primitives — alphabetical within the group.
Expand Down
138 changes: 138 additions & 0 deletions packages/swingset/src/stories/menu.component.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
import * as MenuStories from './menu.component.stories';

# Menu

The Mosaic `Menu` — the styled Mosaic component composed from the `@clerk/headless` menu primitive
and themed with StyleX. It inherits the primitive's positioning, typeahead, roving keyboard
navigation, and ARIA wiring, and adds the trigger, popup surface, and item styling.

## Example

Click the trigger, then use the arrow keys or type to move between items.

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

## Usage

```tsx
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item
label='Sign out'
onClick={signOut}
>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
onClick={deleteUser}
Comment thread
alexcarpenter marked this conversation as resolved.
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>;
```

`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.

### Trigger

With no children, `Menu.Trigger` renders a square ghost `Button` holding an ellipsis glyph. Pass
children for a labelled trigger, or `render` to supply your own element — it receives the computed
props (ARIA attributes, click and keyboard handlers) to spread.

```tsx
<Menu.Trigger>Actions</Menu.Trigger>

<Menu.Trigger render={props => <Avatar {...props} />} />
```

### Items

`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
and text together as children. Use `color='negative'` for destructive actions; the color is
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.

```tsx
<Menu.Item
label='Delete'
color='negative'
>
<Icon name='close' />
Delete
</Menu.Item>
```

### Placement

`Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in
view, and its `max-height` tracks the available space so long menus scroll rather than overflow.

```tsx
<Menu.Root
placement='bottom-end'
sideOffset={8}
>
</Menu.Root>;
```

### Controlled

```tsx
const [open, setOpen] = useState(false);

<Menu.Root
open={open}
onOpenChange={setOpen}
>
</Menu.Root>;
```

## Parts

| Part | Slot | Description |
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. |
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |

## Styling

Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
over `@clerk/ui/styles.css`:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-menu-popup {
border-radius: 20px;
}
}
```

The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style`
attributes and is disabled under `prefers-reduced-motion: reduce`. Item hover state is gated behind
`@media (hover: hover)`, and the keyboard-active item is styled off `data-active`, so pointer and
keyboard highlighting stay in sync.
40 changes: 40 additions & 0 deletions packages/swingset/src/stories/menu.component.stories.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource @emotion/react */
import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Menu } from '@clerk/ui/mosaic/components/menu';

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 './menu.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
Comment on lines +11 to +15

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/bashset -euo pipefail
echo"== candidate story files =="
fd 'menu\.component\.stories\.tsx$'.||trueecho"== target story =="if [ -f packages/swingset/src/stories/menu.component.stories.tsx ];then
cat -n packages/swingset/src/stories/menu.component.stories.tsx
fiecho"== locate StoryMeta/styleEngine definitions =="
rg -n "interface StoryMeta|type StoryMeta|styleEngine|function StoryMeta|export const StoryMeta|const StoryMeta" packages/swingset packages -g '*.ts' -g '*.tsx'||true

Repository: clerk/javascript

Length of output: 2864


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== StoryMeta type =="
cat -n packages/swingset/src/lib/types.ts | sed -n '1,80p'echo"== usages of StyleX components/menu =="
rg -n "packages/ui/src/mosaic/components/menu|components/menu|`@clerk/ui/mosaic/components/menu`|style-engine: \"stylex\"|style-engine: 'stylex'|className: s" packages/ui/src/mosaic/components/menu/menu.tsx packages/ui/src/mosaic -g '*.tsx' -g '*.ts'||trueecho"== inspect menu component =="if [ -f packages/ui/src/mosaic/components/menu/menu.tsx ];then
wc -l packages/ui/src/mosaic/components/menu/menu.tsx
sed -n '1,220p' packages/ui/src/mosaic/components/menu/menu.tsx | cat -n
fi

Repository: clerk/javascript

Length of output: 8206


Mark the Menu story as StyleX-based.

StoryMeta.styleEngine is optional and defaults to 'emotion'; omitting it here makes the docs/show the Emotion sx contract even though Menu is implemented with StyleX className/style.

Proposed fix
 export const meta: StoryMeta = {
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
+ styleEngine: 'stylex',
};
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
};
exportconstmeta: StoryMeta={
group: 'Components',
title: 'Menu',
source: 'packages/ui/src/mosaic/components/menu/menu.tsx',
styleEngine: 'stylex',
};
🤖 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/menu.component.stories.tsx` around lines 11 -
15, Update the Menu story’s meta object to set StoryMeta.styleEngine to the
StyleX engine, alongside the existing group, title, and source fields, so the
story uses the className/style contract instead of the default Emotion sx
contract.


export function Default() {
return (
<Menu.Root>
<Menu.Trigger />
<Menu.Content>
<Menu.Item label='Add workspace'>
<Icon name='plus' />
Add workspace
</Menu.Item>
<Menu.Item label='Sign out'>
<Icon name='log-out' />
Sign out
</Menu.Item>
<Menu.Item
label='Delete user'
color='negative'
>
<Icon name='close' />
Delete user
</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
2 changes: 2 additions & 0 deletions packages/ui/src/mosaic/components/menu/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
115 changes: 115 additions & 0 deletions packages/ui/src/mosaic/components/menu/menu.styles.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';

export const styles = stylex.create({
// Positioning is applied inline by the headless positioner; this only clears the
// focus outline it receives. No z-index: the portalled, fixed positioner already
// paints above page content, and consumers own their own stacking order.
positioner: {
outline: 'none',
Comment thread
alexcarpenter marked this conversation as resolved.
},

popup: {
borderRadius: radiusVars['--cl-radius-element'],
gap: space['0.5'],
outline: 'none',
paddingBlock: space['0.5'],
paddingInline: space['0.5'],
backgroundColor: colorVars['--cl-color-card'],
boxShadow: `0 12px 12px -7px oklch(0.2046 0 0 / 12%),
0 24px 24px -10px oklch(0.2046 0 0 / 4%),
0 0 0 1px oklch(0.2046 0 0 / 4%)`,
boxSizing: 'border-box',
color: colorVars['--cl-color-card-foreground'],
display: 'flex',
flexDirection: 'column',
opacity: {
default: 1,
':is([data-ending-style])': 0,
':is([data-starting-style])': 0,
},
scale: {
default: 1,
':is([data-ending-style])': 0.96,
':is([data-starting-style])': 0.96,
},
// `--cl-transform-origin` is set on the positioner by the headless `cssVars`
// middleware, so the popup scales out of the edge nearest its trigger.
transformOrigin: 'var(--cl-transform-origin)',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'opacity, scale',
transitionTimingFunction: 'ease-out',
maxHeight: 'var(--cl-available-height)',
minWidth: '12.5rem',
overflowY: 'auto',
},

item: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
outline: 'none',
paddingBlock: space['1'],
paddingInline: space['2'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`,
},
},
boxSizing: 'border-box',
color: 'inherit',
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
display: 'flex',
fontFamily: 'inherit',
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-medium'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
opacity: { default: 1, ':is([data-disabled])': 0.5 },
position: 'relative',
textAlign: 'start',
transitionDuration: {
default: '150ms',
'@media (prefers-reduced-motion: reduce)': '0.01ms',
},
transitionProperty: 'background-color',
height: space['7'],
width: '100%',
'::before': {
insetBlock: `calc(-1 * ${space['0.5']})`,
insetInline: `calc(-1 * ${space['0.5']})`,
content: '""',
position: 'absolute',
},
},

itemNegative: {
backgroundColor: {
default: 'transparent',
':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
'@media (hover: hover)': {
':hover': `color-mix(in oklab, ${colorVars['--cl-color-negative']} 8%, transparent)`,
},
},
color: colorVars['--cl-color-negative'],
},

separator: {
// Full-bleed across the popup: cancel the popup's inline padding.
marginBlock: space['0.5'],
marginInline: `calc(-1 * ${space['0.5']})`,
backgroundColor: colorVars['--cl-color-border'],
blockSize: '1px',
},

triggerIcon: {
height: space['4'],
width: space['4'],
},
});
Loading
Loading