diff --git a/.changeset/mosaic-action-bar.md b/.changeset/mosaic-action-bar.md
new file mode 100644
index 00000000000..a763ac19988
--- /dev/null
+++ b/.changeset/mosaic-action-bar.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Add an ActionBar component for contextual actions on a current selection.
diff --git a/packages/mosaic/src/components/action-bar/action-bar.styles.ts b/packages/mosaic/src/components/action-bar/action-bar.styles.ts
new file mode 100644
index 00000000000..a96738d8a0f
--- /dev/null
+++ b/packages/mosaic/src/components/action-bar/action-bar.styles.ts
@@ -0,0 +1,82 @@
+import * as stylex from '@stylexjs/stylex';
+
+import {
+ colorVars,
+ durationVars,
+ easingVars,
+ fontWeightVars,
+ radiusVars,
+ shadowVars,
+ space,
+ typeScaleVars,
+} from '../../tokens.stylex';
+
+const reduceMotion = '@media (prefers-reduced-motion: reduce)';
+
+export const styles = stylex.create({
+ positioner: {
+ display: 'flex',
+ justifyContent: 'center',
+ pointerEvents: 'none',
+ zIndex: 1,
+ bottom: space['2'],
+ },
+ // Anchored to the nearest positioned ancestor — for a bounded container.
+ positionerAbsolute: {
+ insetInline: 0,
+ position: 'absolute',
+ },
+ // Pinned to the foot of the nearest scroll container, so it stays in view while its surface
+ // scrolls. `margin-block-start: auto` drops it to the bottom when the column has room to spare.
+ positionerSticky: {
+ marginBlockStart: 'auto',
+ position: 'sticky',
+ },
+ bar: {
+ borderRadius: radiusVars['--cl-radius-lg'],
+ gap: space['1'],
+ paddingBlock: space['1'],
+ paddingInline: space['1'],
+ alignItems: 'center',
+ backgroundColor: colorVars['--cl-color-background'],
+ boxShadow: shadowVars['--cl-shadow-lg'],
+ display: 'flex',
+ opacity: {
+ default: 1,
+ ':is([data-open="false"])': 0,
+ },
+ pointerEvents: {
+ default: 'auto',
+ ':is([data-open="false"])': 'none',
+ },
+ transform: {
+ default: 'translateY(0)',
+ ':is([data-open="false"])': 'translateY(0.25rem)',
+ },
+ transitionDuration: {
+ default: `${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`,
+ [reduceMotion]: `${durationVars['--cl-duration-instant']}, ${durationVars['--cl-duration-instant']}`,
+ ':is([data-open="false"])': `${durationVars['--cl-duration-instant']}, ${durationVars['--cl-duration-instant']}`,
+ },
+ transitionProperty: 'opacity, transform',
+ transitionTimingFunction: {
+ default: `linear, ${easingVars['--cl-ease-enter']}`,
+ },
+ maxWidth: 'calc(100% - 2 * var(--cl-spacing))',
+ },
+ count: {
+ paddingInline: space['2'],
+ color: colorVars['--cl-color-foreground'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ whiteSpace: 'nowrap',
+ },
+ separator: {
+ marginBlock: space['1'],
+ marginInline: space['0.5'],
+ alignSelf: 'stretch',
+ backgroundColor: colorVars['--cl-color-border'],
+ width: '1px',
+ },
+});
diff --git a/packages/mosaic/src/components/action-bar/action-bar.test.tsx b/packages/mosaic/src/components/action-bar/action-bar.test.tsx
new file mode 100644
index 00000000000..3eead27ce7b
--- /dev/null
+++ b/packages/mosaic/src/components/action-bar/action-bar.test.tsx
@@ -0,0 +1,73 @@
+import * as stylex from '@stylexjs/stylex';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ActionBar } from './action-bar';
+
+const testStyles = stylex.create({
+ positioner: {
+ bottom: '20px',
+ },
+});
+
+describe('Mosaic ActionBar', () => {
+ it('renders a toolbar with its count and reflects data-open', () => {
+ render(
+
+ 3 selected
+ ,
+ );
+ const bar = screen.getByRole('toolbar', { name: 'Bulk actions' });
+ expect(bar).toHaveClass('cl-action-bar');
+ expect(bar).toHaveAttribute('data-open', 'true');
+ const count = screen.getByText('3 selected');
+ expect(count).toHaveClass('cl-action-bar-count');
+ expect(count.querySelector('.cl-icon')).not.toBeInTheDocument();
+ });
+
+ it('marks the bar inert while closed', () => {
+ render(
+
+ 0 selected
+ ,
+ );
+ const bar = screen.getByRole('toolbar', { name: 'Bulk actions', hidden: true });
+ expect(bar).toHaveAttribute('data-open', 'false');
+ expect(bar.inert).toBe(true);
+ });
+
+ it('styles the positioner independently from the bar', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('toolbar', { name: 'Bulk actions' }).parentElement).toHaveClass(
+ ...(stylex.props(testStyles.positioner).className as string).split(' '),
+ );
+ });
+
+ it('dismisses with a labelled button', async () => {
+ const onDismiss = vi.fn();
+ render(
+
+
+ ,
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Clear selection' }));
+ expect(onDismiss).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx
new file mode 100644
index 00000000000..7e420719505
--- /dev/null
+++ b/packages/mosaic/src/components/action-bar/action-bar.tsx
@@ -0,0 +1,148 @@
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import { useRender } from '../../primitives/utils';
+import type { MosaicComponentProps, MosaicElementProps, XStyle } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { reset } from '../../utils/reset.styles';
+import { Button } from '../button';
+import { Icon } from '../icon';
+import { styles } from './action-bar.styles';
+
+export interface ActionBarRootProps extends MosaicComponentProps<'div'> {
+ /** Whether the bar is shown. Toggling it animates the bar in and out. */
+ open: boolean;
+ /**
+ * Pins the bar to the foot of the nearest scroll container instead of a positioned ancestor, so
+ * it stays in view while a long surface scrolls (a `Profile` page, a modal). Place `ActionBar.Root`
+ * as the last child of that scrolling column. Off, the bar is absolute within the nearest
+ * positioned ancestor — give that ancestor `position: relative`.
+ *
+ * @default false
+ */
+ sticky?: boolean;
+ positionerXstyle?: XStyle;
+}
+
+/**
+ * A floating bar of actions for a selection. It sits at the foot of its container — the nearest
+ * positioned ancestor, or the nearest scroll container with `sticky` — and animates in and out with
+ * `open`. While `open` is false it is inert and click-through, so the surface underneath stays
+ * usable. Compose a count, a separator, the actions, and a dismiss:
+ *
+ * @example
+ * 0}>
+ * {count} selected
+ *
+ * …
+ *
+ *
+ *
+ *
+ */
+const Root = React.forwardRef(function ActionBarRoot(
+ { open, sticky = false, render, xstyle, positionerXstyle, children, ...rest },
+ ref,
+) {
+ const barRef = React.useRef(null);
+ React.useEffect(() => {
+ if (barRef.current) {
+ barRef.current.inert = !open;
+ }
+ }, [open]);
+
+ const bar = useRender({
+ defaultTagName: 'div',
+ render,
+ ref: barRef,
+ props: {
+ ...mergeStyleProps(themeProps('action-bar', { open }), stylex.props(reset.base, styles.bar, xstyle), rest),
+ 'data-open': open,
+ role: 'toolbar',
+ children,
+ },
+ });
+
+ return (
+
+ {bar}
+
+ );
+});
+
+export type ActionBarCountProps = MosaicComponentProps<'div'>;
+
+/** The leading count of what is selected, e.g. `3 selected`. */
+const Count = React.forwardRef(function ActionBarCount(
+ { render, xstyle, children, ...rest },
+ ref,
+) {
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('action-bar-count'), stylex.props(reset.base, styles.count, xstyle), rest),
+ children,
+ },
+ });
+});
+
+export type ActionBarSeparatorProps = MosaicComponentProps<'div'>;
+
+/** A vertical divider between groups of the bar. */
+const Separator = React.forwardRef(function ActionBarSeparator(
+ { render, xstyle, ...rest },
+ ref,
+) {
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(themeProps('action-bar-separator'), stylex.props(reset.base, styles.separator), rest),
+ 'aria-hidden': true,
+ },
+ });
+});
+
+export type ActionBarDismissProps = MosaicElementProps<'button'>;
+
+/** Dismisses the bar. A ghost icon button; defaults its label to "Clear selection". */
+const Dismiss = React.forwardRef(function ActionBarDismiss(
+ { xstyle, 'aria-label': ariaLabel, ...rest },
+ ref,
+) {
+ return (
+
+ );
+});
+
+/**
+ * A floating bar of actions for a current selection, composed through `ActionBar.Root`,
+ * `ActionBar.Count`, `ActionBar.Separator`, and `ActionBar.Dismiss`. The actions themselves
+ * (a `Menu`, a `Button`) are whatever children you place between them.
+ */
+export const ActionBar = { Root, Count, Separator, Dismiss };
diff --git a/packages/mosaic/src/components/action-bar/index.ts b/packages/mosaic/src/components/action-bar/index.ts
new file mode 100644
index 00000000000..fb244e5be89
--- /dev/null
+++ b/packages/mosaic/src/components/action-bar/index.ts
@@ -0,0 +1,7 @@
+export { ActionBar } from './action-bar';
+export type {
+ ActionBarCountProps,
+ ActionBarDismissProps,
+ ActionBarRootProps,
+ ActionBarSeparatorProps,
+} from './action-bar';
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 776276f8f42..e746c980d4a 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -47,6 +47,7 @@ const docModules: Record> = {
destructive: dynamic(() => import('../stories/destructive.mdx')),
},
components: {
+ 'action-bar': dynamic(() => import('../stories/action-bar.mdx')),
avatar: dynamic(() => import('../stories/avatar.mdx')),
badge: dynamic(() => import('../stories/badge.mdx')),
banner: dynamic(() => import('../stories/banner.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 3a7740c8233..c8662720f27 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -1,5 +1,6 @@
// Import stories explicitly to control order and avoid type casting through unknown.
import { meta as accordionMeta } from '../stories/accordion.stories';
+import { Default as ActionBarDefault, meta as actionBarMeta } from '../stories/action-bar.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Fallback as AvatarFallbackStory,
@@ -397,6 +398,11 @@ const comboboxModule: StoryModule = {
Scrolling: ComboboxScrolling,
};
+const actionBarModule: StoryModule = {
+ meta: actionBarMeta,
+ Default: ActionBarDefault,
+};
+
const avatarModule: StoryModule = {
meta: avatarMeta,
Primary: AvatarPrimary,
@@ -791,6 +797,7 @@ export const registry: StoryModule[] = [
confirmationModule,
destructiveModule,
// Components
+ actionBarModule,
avatarModule,
badgeModule,
bannerModule,
diff --git a/packages/swingset/src/stories/action-bar.mdx b/packages/swingset/src/stories/action-bar.mdx
new file mode 100644
index 00000000000..a1ae780716f
--- /dev/null
+++ b/packages/swingset/src/stories/action-bar.mdx
@@ -0,0 +1,49 @@
+import * as ActionBarStories from './action-bar.stories';
+
+# ActionBar
+
+A floating bar of actions for a current selection. It sits at the foot of its nearest positioned
+ancestor — give that ancestor `position: relative` and the bar centers over its bottom — and
+animates in when opened. While closed it is inert and click-through, so the surface
+underneath stays usable.
+
+## Example
+
+
+
+## Usage
+
+```tsx
+ 0}>
+ {selectedCount} selected
+
+ {/* Change role */}
+
+
+
+
+```
+
+## Parts
+
+| Part | Element | Description |
+| --------------------- | -------- | -------------------------------------------------------------------------- |
+| `ActionBar.Root` | `div` | The positioner + floating surface. Takes `open`; renders `role='toolbar'`. |
+| `ActionBar.Count` | `div` | The leading count of what is selected. |
+| `ActionBar.Separator` | `div` | A vertical divider between groups. |
+| `ActionBar.Dismiss` | `button` | Clears the selection. A ghost icon button; labelled `Clear selection`. |
+
+## Styling
+
+`ActionBar.Root` reflects `data-open` (`true` / `false`) on its surface, so the entrance
+transition and the closed (click-through) state are both selectable. The actions themselves are
+whatever children you place between the parts.
diff --git a/packages/swingset/src/stories/action-bar.stories.tsx b/packages/swingset/src/stories/action-bar.stories.tsx
new file mode 100644
index 00000000000..b099c00cbb3
--- /dev/null
+++ b/packages/swingset/src/stories/action-bar.stories.tsx
@@ -0,0 +1,80 @@
+import { ActionBar } from '@clerk/mosaic/components/action-bar';
+import { Button } from '@clerk/mosaic/components/button';
+import { Icon } from '@clerk/mosaic/components/icon';
+import { Menu } from '@clerk/mosaic/components/menu';
+import { useState } from 'react';
+
+import type { StoryMeta } from '@/lib/types';
+
+export { default as __source } from './action-bar.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Components',
+ title: 'ActionBar',
+ status: 'wip',
+ layout: 'wide',
+ source: 'packages/mosaic/src/components/action-bar/action-bar.tsx',
+};
+
+/**
+ * The bar sits at the foot of its nearest positioned ancestor. The example gives that ancestor a
+ * height and `position: relative` so the bar floats over its bottom; toggle the selection to see
+ * it animate in and out.
+ */
+export function Default() {
+ const [count, setCount] = useState(3);
+ return (
+