diff --git a/.changeset/mosaic-radius-scale.md b/.changeset/mosaic-radius-scale.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-radius-scale.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/popover-freeze-on-close.md b/.changeset/popover-freeze-on-close.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/popover-freeze-on-close.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/user-button-switcher.md b/.changeset/user-button-switcher.md
new file mode 100644
index 00000000000..0c8ed4b756d
--- /dev/null
+++ b/.changeset/user-button-switcher.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': minor
+---
+
+Add the `UserButton` Mosaic component: an account and organization switcher that combines multi-session account switching with organization selection, suggestions, and invitations behind a single popover. Exposes the all-in-one `UserButton` plus the composable `UserButtonRoot`, `UserButtonTrigger`, and `UserButtonPopup` parts. `mode` narrows what the surface carries: `combined` (the default) lists organizations and accounts together, `orgs` is an organization switcher with no account rows, and `user` is an account switcher that never shows an organization. It owns no slots of its own: it is composed from `Popover`, `Card`, `Item`, `Avatar` and `Menu`, so `appearance.elements` overrides for those components theme it too.
diff --git a/packages/headless/src/primitives/menu/README.md b/packages/headless/src/primitives/menu/README.md
index ee2c9dda87e..4ac10b6dac2 100644
--- a/packages/headless/src/primitives/menu/README.md
+++ b/packages/headless/src/primitives/menu/README.md
@@ -139,7 +139,7 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall
- Nested menus open on hover (75ms delay) with a `safePolygon` safe zone.
- Only one sibling submenu can be open at a time.
- Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event.
-- `Escape` closes the innermost menu first, bubbling up through the tree.
+- `Escape` closes one level: the innermost open menu, leaving its parent — a parent menu, or a `Popover` the menu is rendered inside — open. Pressing it again closes the next level up. An outside press is the opposite: it dismisses the whole stack at once.
## Important Notes
diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx
index f8ac956d40c..8c2725193d2 100644
--- a/packages/headless/src/primitives/menu/menu-root.tsx
+++ b/packages/headless/src/primitives/menu/menu-root.tsx
@@ -45,7 +45,10 @@ function MenuInner(props: MenuProps) {
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const parentId = useFloatingParentNodeId();
- const isNested = parentId != null;
+ // A submenu, not merely a menu inside some other floating element. A menu rendered in a popover
+ // has a parent node id too, and treating that as nesting makes it hover-open, side-placed, and
+ // unclickable by mouse.
+ const isNested = parentId != null && parentContext != null;
const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
@@ -98,7 +101,9 @@ function MenuInner(props: MenuProps) {
ignoreMouse: isNested,
});
const role = useRole(floatingContext, { role: 'menu' });
- const dismiss = useDismiss(floatingContext, { bubbles: true });
+ // Escape must not bubble: it closes this menu and leaves whatever it sits inside — a parent menu,
+ // or a popover — open. An outside press is the opposite, and dismisses the whole stack.
+ const dismiss = useDismiss(floatingContext, { bubbles: { escapeKey: false, outsidePress: true } });
const listNavigation = useListNavigation(floatingContext, {
listRef: elementsRef,
activeIndex,
diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx
index dbc19745c69..dfa2fb58c19 100644
--- a/packages/headless/src/primitives/menu/menu.test.tsx
+++ b/packages/headless/src/primitives/menu/menu.test.tsx
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { axe } from '../../test-utils/axe';
+import { Popover } from '../popover';
import { Menu } from './index';
afterEach(() => cleanup());
@@ -614,6 +615,80 @@ describe('Menu', () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
+
+ it('Escape closes only the submenu', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Share
+
+
+ Email
+
+
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await new Promise(r => requestAnimationFrame(r));
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowRight}');
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByText('Share')).toHaveAttribute('data-closed', '');
+ expect(screen.getByText('Actions')).toHaveAttribute('data-open', '');
+ });
+ });
+
+ describe('inside a popover', () => {
+ function renderMenuInPopover() {
+ return render(
+
+ Open popover
+
+
+
+ Actions
+
+
+ Cut
+
+
+
+
+
+ ,
+ );
+ }
+
+ it('Escape closes only the menu', async () => {
+ const user = userEvent.setup();
+ renderMenuInPopover();
+
+ await user.click(screen.getByText('Actions'));
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByText('Actions')).toHaveAttribute('data-closed', '');
+ expect(screen.getByText('Open popover')).toHaveAttribute('data-open', '');
+ });
+
+ it('Escape closes the popover once the menu is closed', async () => {
+ const user = userEvent.setup();
+ renderMenuInPopover();
+
+ await user.click(screen.getByText('Actions'));
+ await user.keyboard('{Escape}');
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByText('Open popover')).toHaveAttribute('data-closed', '');
+ });
});
describe('positioner', () => {
diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md
index 58ceb1d53aa..5e75920e4b1 100644
--- a/packages/headless/src/primitives/popover/README.md
+++ b/packages/headless/src/primitives/popover/README.md
@@ -63,14 +63,15 @@ const [open, setOpen] = useState(false);
### `Popover.Root`
-| Prop | Type | Default | Description |
-| -------------- | ------------------------- | ---------- | ---------------------------------- |
-| `open` | `boolean` | — | Controlled open state |
-| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
-| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
-| `placement` | `Placement` | `"bottom"` | Floating UI placement |
-| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
-| `modal` | `boolean` | `false` | Traps focus within the popover |
+| Prop | Type | Default | Description |
+| -------------- | ------------------------- | ---------- | ----------------------------------- |
+| `open` | `boolean` | — | Controlled open state |
+| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
+| `placement` | `Placement` | `"bottom"` | Floating UI placement |
+| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |
+| `alignOffset` | `number` | `0` | Nudge along the alignment axis (px) |
+| `modal` | `boolean` | `false` | Traps focus within the popover |
### `Popover.Trigger`, `Popover.Positioner`, `Popover.Popup`, `Popover.Title`, `Popover.Description`, `Popover.Close`
@@ -104,6 +105,7 @@ Middleware stack: `offset` -> `flip` -> `shift` -> `arrow` -> CSS vars. The popu
- **Title and Description are optional but recommended.** They wire `aria-labelledby` and `aria-describedby` to the positioner. If omitted, those attributes are simply absent.
- **Non-modal by default.** Unlike Dialog, the page remains interactive behind the popover. Set `modal={true}` for a stricter focus trap.
- **Nested popovers are supported.** The `FloatingTree` pattern handles nesting automatically.
+- **Popup contents freeze while closing.** The popup outlives `open` by its exit animation, so its children are wrapped in `Freeze` (`@clerk/headless/utils`) and hold their last frame instead of re-rendering under the animation. The popup element itself keeps updating, so `data-closed` / `data-ending-style` still land. Freezing wraps the children in a `display: contents` element and detaches refs inside them until the popup reopens.
## ARIA
diff --git a/packages/headless/src/primitives/popover/popover-popup.tsx b/packages/headless/src/primitives/popover/popover-popup.tsx
index 8f8d5b5a13b..64e2bcbd1da 100644
--- a/packages/headless/src/primitives/popover/popover-popup.tsx
+++ b/packages/headless/src/primitives/popover/popover-popup.tsx
@@ -2,17 +2,22 @@
import React from 'react';
-import { type ComponentProps, mergeProps, useRender } from '../../utils';
+import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils';
import { usePopoverContext } from './popover-context';
export type PopoverPopupProps = ComponentProps<'div'>;
export const PopoverPopup = React.forwardRef(function PopoverPopup(props, ref) {
- const { render, ...otherProps } = props;
- const { popupRef, transitionProps } = usePopoverContext();
+ const { render, children, ...otherProps } = props;
+ const { open, popupRef, transitionProps } = usePopoverContext();
const defaultProps = {
...transitionProps,
+ // The popup outlives `open` by the length of its exit animation. Whatever closed it has
+ // usually changed the data behind it (switching account, picking an item), so the contents
+ // hold their last frame on the way out instead of swapping under the animation. The popup
+ // element itself stays live, so `data-closed` / `data-ending-style` still land.
+ children: {children},
};
return useRender({
diff --git a/packages/headless/src/primitives/popover/popover-root.tsx b/packages/headless/src/primitives/popover/popover-root.tsx
index 3edf3efabb4..0f502c5d711 100644
--- a/packages/headless/src/primitives/popover/popover-root.tsx
+++ b/packages/headless/src/primitives/popover/popover-root.tsx
@@ -30,13 +30,14 @@ export interface PopoverProps {
onOpenChange?: (open: boolean) => void;
placement?: Placement;
sideOffset?: number;
+ alignOffset?: number;
modal?: boolean;
children: ReactNode;
}
function PopoverInner(props: PopoverProps) {
const nodeId = useFloatingNodeId();
- const { placement: placementProp = 'bottom', sideOffset = 4, modal = false, children } = props;
+ const { placement: placementProp = 'bottom', sideOffset = 4, alignOffset = 0, modal = false, children } = props;
const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
@@ -61,7 +62,7 @@ function PopoverInner(props: PopoverProps) {
onOpenChange: setOpen,
placement: placementProp,
middleware: [
- offset(sideOffset),
+ offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),
flip({
crossAxis: placementProp.includes('-'),
fallbackAxisSideDirection: 'end',
diff --git a/packages/headless/src/utils/freeze.test.tsx b/packages/headless/src/utils/freeze.test.tsx
new file mode 100644
index 00000000000..b0fb5adf2cd
--- /dev/null
+++ b/packages/headless/src/utils/freeze.test.tsx
@@ -0,0 +1,72 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { Freeze } from './freeze';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('Freeze', () => {
+ it('renders children while not frozen', () => {
+ render(Acme);
+
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ });
+
+ it('holds the committed DOM when children change while frozen', () => {
+ const { rerender } = render(Acme);
+
+ rerender(Globex);
+
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Globex')).toBeNull();
+ });
+
+ it('keeps the held DOM visible', () => {
+ const { rerender } = render(Acme);
+
+ rerender(Globex);
+
+ expect(screen.getByText('Acme')).toBeVisible();
+ });
+
+ it('keeps the held DOM visible across further updates while frozen', () => {
+ const { rerender } = render(Acme);
+
+ rerender(Globex);
+ rerender(Initech);
+
+ expect(screen.getByText('Acme')).toBeVisible();
+ });
+
+ it('commits the pending children once unfrozen', () => {
+ const { rerender } = render(Acme);
+
+ rerender(Globex);
+ rerender(Globex);
+
+ expect(screen.getByText('Globex')).toBeInTheDocument();
+ expect(screen.queryByText('Acme')).toBeNull();
+ });
+
+ it('holds state updates raised from inside the frozen subtree', () => {
+ function Counter({ count }: { count: number }) {
+ return count: {count};
+ }
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByText('count: 0')).toBeInTheDocument();
+ });
+});
diff --git a/packages/headless/src/utils/freeze.tsx b/packages/headless/src/utils/freeze.tsx
new file mode 100644
index 00000000000..bdb5469ebf6
--- /dev/null
+++ b/packages/headless/src/utils/freeze.tsx
@@ -0,0 +1,64 @@
+'use client';
+
+import * as React from 'react';
+
+/**
+ * Never settles. Throwing it suspends the enclosing boundary indefinitely: React keeps
+ * rendering the subtree but holds the commit, so the DOM keeps painting its last frame.
+ */
+const never = new Promise(() => {});
+
+function Suspend(): null {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol, not an error. `React.use()` would say this more plainly but needs React 19.2; this package supports React 18.
+ throw never;
+}
+
+export interface FreezeProps {
+ /** While `true`, the DOM below holds whatever it last committed. */
+ frozen: boolean;
+ children?: React.ReactNode;
+}
+
+/**
+ * Holds its subtree's DOM at the last committed frame while `frozen`. Renders keep
+ * happening, they just don't reach the DOM; the pending one commits when `frozen` flips
+ * back to `false`.
+ *
+ * Use it to stop content from visibly changing under an exit animation — a popover that
+ * closes because the thing it was showing changed would otherwise swap its contents on the
+ * way out.
+ */
+export function Freeze({ frozen, children }: FreezeProps) {
+ const contentRef = React.useRef(null);
+
+ // Hold onto the node ourselves rather than reading a plain ref: hiding a boundary's children
+ // detaches their refs, so by the time the effect below runs a normal ref reads `null`.
+ const setContent = React.useCallback((node: HTMLDivElement | null) => {
+ if (node) {
+ contentRef.current = node;
+ }
+ }, []);
+
+ // React hides a suspended boundary's host children with `display: none !important`, which is
+ // the opposite of what this is for. Undo it on the commit that applies it: insertion effects
+ // run after the boundary's mutation and before paint, so the held frame never blinks out.
+ // `display: contents` is also what the wrapper renders with, so React puts it back on unfreeze
+ // and the wrapper stays out of the layout it is spliced into.
+ React.useInsertionEffect(() => {
+ if (frozen) {
+ contentRef.current?.style.setProperty('display', 'contents');
+ }
+ }, [frozen]);
+
+ return (
+
+ {frozen ? : null}
+
+ {children}
+
+
+ );
+}
diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts
index f2a2d4c17c4..87856e7a9c2 100644
--- a/packages/headless/src/utils/index.ts
+++ b/packages/headless/src/utils/index.ts
@@ -1,4 +1,5 @@
export { cssVars } from './css-vars';
+export { Freeze, type FreezeProps } from './freeze';
export { resetLayoutStyles } from './reset-layout-styles';
export {
type ComponentProps,
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index d54119bbc35..273efb6e7af 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource';
// MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named
// entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct.
const docModules: Record> = {
+ user: {
+ 'user-button': dynamic(() => import('../stories/user-button.mdx')),
+ },
organization: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 3b1e386414c..755bb25f6ff 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -105,6 +105,12 @@ import {
} from '../stories/text.stories';
import { meta as tooltipMeta } from '../stories/tooltip.stories';
import { meta as useDataTableMeta } from '../stories/use-data-table.stories';
+import {
+ Combined as UserButtonCombined,
+ meta as userButtonMeta,
+ Organizations as UserButtonOrganizations,
+ User as UserButtonUser,
+} from '../stories/user-button.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';
@@ -176,6 +182,13 @@ const itemModule: StoryModule = {
Group: ItemGroup,
};
+const userButtonModule: StoryModule = {
+ meta: userButtonMeta,
+ Combined: UserButtonCombined,
+ Organizations: UserButtonOrganizations,
+ User: UserButtonUser,
+};
+
const headingModule: StoryModule = {
meta: headingMeta,
Default: HeadingDefault,
@@ -216,6 +229,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta };
const useDataTableModule: StoryModule = { meta: useDataTableMeta };
export const registry: StoryModule[] = [
+ // User
+ userButtonModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx
index 2aea12d0cac..e8984ede7b5 100644
--- a/packages/swingset/src/stories/item.mdx
+++ b/packages/swingset/src/stories/item.mdx
@@ -59,12 +59,12 @@ import { Item } from '@clerk/ui/mosaic/components/item';
;
```
-Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'`. An action row that has no secondary text uses `Item.Label` in place of `Item.Title`:
+Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an `Icon`. An action row that has no secondary text uses `Item.Label` in place of `Item.Title`:
```tsx
}>
-
+ Sign out of all accounts
@@ -74,22 +74,24 @@ Media sizes itself from the row, so give it a child that fills its column — an
## Parts
-| Part | Class | Description |
-| ------------------ | --------------------- | -------------------------------------------------------------------------- |
-| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. |
-| `Item.Media` | `cl-item-media` | Square leading column: icon, image, or avatar. Sized by the root's `size`. |
-| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. |
-| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. |
-| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. |
-| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Dimmed until hovered. |
-| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
-| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). |
-| `Item.Separator` | `cl-item-separator` | Thin divider (``) between rows. |
+| Part | Class | Description |
+| ------------------ | --------------------- | -------------------------------------------------------------------------------- |
+| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. |
+| `Item.Media` | `cl-item-media` | Square leading column: icon, image, or avatar. Sized by the root's `size`. |
+| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. |
+| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. |
+| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. |
+| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Inherits the row's text color. |
+| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
+| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). |
+| `Item.Separator` | `cl-item-separator` | Thin divider (``) between rows. |
Every part accepts a `render` prop for element polymorphism and forwards a ref.
## Styling
+The row carries the text color and, through `--_cl-icon-color`, the strength of any `Icon` inside it: faded at rest, full-strength while hovered. Only interactive rows promote — a static row isn't pointing at anything, so its icon and `Item.Label` hold their resting color. A `Button` in `Item.Actions` sets its own icon color and is unaffected.
+
The root reflects its state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms:
| Prop | Attribute | Values | Default |
@@ -111,4 +113,4 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c
}
```
-`Item.Media` is a square that centers its child. Because the column is sized by the row, give it a child that fills it — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'` — rather than a fixed pixel size that won't track `size`. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`).
+`Item.Media` is a square that centers its child. Because the column is sized by the row, give it a child that fills it — an `Avatar.Root` with `size='fit'`, or an `Icon` — rather than a fixed pixel size that won't track `size`. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`).
diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx
index 4c63b08424d..e8d5dad5fb3 100644
--- a/packages/swingset/src/stories/item.stories.tsx
+++ b/packages/swingset/src/stories/item.stories.tsx
@@ -1,8 +1,8 @@
/** @jsxImportSource @emotion/react */
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
import { Button } from '@clerk/ui/mosaic/components/button';
+import { Icon } from '@clerk/ui/mosaic/components/icon';
import { Item } from '@clerk/ui/mosaic/components/item';
-import * as React from 'react';
import type { StoryMeta } from '@/lib/types';
@@ -16,68 +16,6 @@ export const meta: StoryMeta = {
source: 'packages/ui/src/mosaic/components/item/item.tsx',
};
-function CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- );
-}
-
-function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- );
-}
-
-function SignOutIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
- return (
-
- );
-}
-
export function Default() {
return (
@@ -198,23 +136,7 @@ export function Group() {
size='sm'
shape='square'
>
-
+
@@ -234,7 +156,10 @@ export function Group() {
Clerk
-
+
-
+ Add account
@@ -322,7 +250,10 @@ export function Group() {
)}
>
-
+ Sign out of all accounts
diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx
index 08dc13b078a..9489b27e933 100644
--- a/packages/swingset/src/stories/popover.component.mdx
+++ b/packages/swingset/src/stories/popover.component.mdx
@@ -145,16 +145,37 @@ centering on it.
Cross-axis flipping is only enabled for aligned placements, so `bottom-start` may become
`bottom-end` near a viewport edge while a plain `bottom` will not.
+`alignOffset` nudges the popup along that alignment axis, the way `sideOffset` does along the side.
+Use it to cancel padding inside the popup so its content, rather than its edge, lines up with the
+trigger — a negative value pulls a `-start` placement further left.
+
+```tsx
+
+ Open
+
+
+ Pulled 8px left of the trigger's start edge.
+
+
+;
+```
+
+It is a preference like placement is: `shift` still claws the popup back when the nudge would push
+it out of view.
+
## Parts
-| Part | Slot | Description |
-| --------------------- | --------------- | ----------------------------------------------------------------------- |
-| `Popover.Root` | — | State provider; owns open/close, `placement`, `sideOffset`, `modal`. |
-| `Popover.Trigger` | — | Anchor element; renders a `