diff --git a/.changeset/mosaic-separator.md b/.changeset/mosaic-separator.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-separator.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/components/separator/index.ts b/packages/mosaic/src/components/separator/index.ts
new file mode 100644
index 00000000000..49434482dbb
--- /dev/null
+++ b/packages/mosaic/src/components/separator/index.ts
@@ -0,0 +1,2 @@
+export { Separator } from './separator';
+export type { SeparatorProps } from './separator';
diff --git a/packages/mosaic/src/components/separator/separator.styles.ts b/packages/mosaic/src/components/separator/separator.styles.ts
new file mode 100644
index 00000000000..16216a64e20
--- /dev/null
+++ b/packages/mosaic/src/components/separator/separator.styles.ts
@@ -0,0 +1,33 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, fontFamilyVars, space, typeScaleVars } from '../../tokens.stylex';
+
+export const styles = stylex.create({
+ plain: {
+ backgroundColor: colorVars['--cl-color-border'],
+ height: '1px',
+ width: '100%',
+ },
+ labelled: {
+ gap: space['4'],
+ alignItems: 'center',
+ color: colorVars['--cl-color-foreground-secondary'],
+ display: 'flex',
+ fontFamily: fontFamilyVars['--cl-font-family-sans'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ width: '100%',
+ '::after': {
+ backgroundColor: colorVars['--cl-color-border'],
+ content: '""',
+ flexGrow: 1,
+ height: '1px',
+ },
+ '::before': {
+ backgroundColor: colorVars['--cl-color-border'],
+ content: '""',
+ flexGrow: 1,
+ height: '1px',
+ },
+ },
+});
diff --git a/packages/mosaic/src/components/separator/separator.test.tsx b/packages/mosaic/src/components/separator/separator.test.tsx
new file mode 100644
index 00000000000..2af7a693c85
--- /dev/null
+++ b/packages/mosaic/src/components/separator/separator.test.tsx
@@ -0,0 +1,55 @@
+import * as stylex from '@stylexjs/stylex';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { Separator } from './separator';
+
+const atoms = stylex.create({
+ spaced: { marginTop: '8px' },
+});
+
+describe('Mosaic Separator', () => {
+ it('renders a plain separator without children', () => {
+ render();
+ const separator = screen.getByRole('separator');
+ expect(separator).toHaveClass('cl-separator');
+ expect(separator).not.toHaveAttribute('data-labelled');
+ expect(separator).toBeEmptyDOMElement();
+ });
+
+ it('renders a labelled separator with children', () => {
+ render(or);
+ const separator = screen.getByRole('separator');
+ expect(separator).toHaveTextContent('or');
+ expect(separator).toHaveAttribute('data-labelled', '');
+ });
+
+ it.each([
+ ['null', null],
+ ['false', false],
+ ['an empty string', ''],
+ ])('renders the plain separator when children is %s', (_, children) => {
+ render({children});
+ expect(screen.getByRole('separator')).not.toHaveAttribute('data-labelled');
+ });
+
+ it('merges xstyle atoms after the slot atoms', () => {
+ render();
+ expect(screen.getByRole('separator')).toHaveClass('cl-separator', stylex.props(atoms.spaced).className ?? '');
+ });
+
+ it('renders a custom element via render, keeping the styling contract', () => {
+ render(}>or);
+ const separator = screen.getByRole('separator');
+ expect(separator.tagName).toBe('SECTION');
+ expect(separator).toHaveClass('cl-separator');
+ expect(separator).toHaveAttribute('data-labelled', '');
+ });
+
+ it('forwards the ref', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).toBe(screen.getByRole('separator'));
+ });
+});
diff --git a/packages/mosaic/src/components/separator/separator.tsx b/packages/mosaic/src/components/separator/separator.tsx
new file mode 100644
index 00000000000..632894797a5
--- /dev/null
+++ b/packages/mosaic/src/components/separator/separator.tsx
@@ -0,0 +1,45 @@
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import { useRender } from '../../primitives/utils';
+import type { MosaicComponentProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { reset } from '../../styles/reset.styles';
+import { styles } from './separator.styles';
+
+export type SeparatorProps = MosaicComponentProps<'div'>;
+
+/**
+ * A horizontal rule between blocks of content. Without children it is a plain 1px line;
+ * with children it is a labelled divider, with a line on each side of the label.
+ *
+ * @example
+ *
+ *
+ * @example
+ * or
+ */
+export const Separator = React.forwardRef(function MosaicSeparator(
+ { children, render, xstyle, ...rest },
+ ref,
+) {
+ const labelled = hasLabel(children);
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: {
+ ...mergeStyleProps(
+ themeProps('separator', { labelled }),
+ stylex.props(reset.base, labelled ? styles.labelled : styles.plain, xstyle),
+ rest,
+ ),
+ role: 'separator',
+ children,
+ },
+ });
+});
+
+function hasLabel(children: React.ReactNode): boolean {
+ return children != null && children !== false && children !== '';
+}
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index 47c479b561f..5b0848cf3ae 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -120,6 +120,8 @@ export type {
} from '../components/table';
export { SegmentedControl } from '../components/segmented-control';
export type { SegmentedControlItemProps, SegmentedControlRootProps } from '../components/segmented-control';
+export { Separator } from '../components/separator';
+export type { SeparatorProps } from '../components/separator';
export { Tabs } from '../components/tabs';
export type {
TabsListProps,
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 8da091501ad..70a536937e0 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -98,6 +98,7 @@ const docModules: Record> = {
radio: dynamic(() => import('../stories/radio.mdx')),
reveal: dynamic(() => import('../stories/reveal.component.mdx')),
section: dynamic(() => import('../stories/section.mdx')),
+ separator: dynamic(() => import('../stories/separator.mdx')),
'segmented-control': dynamic(() => import('../stories/segmented-control.mdx')),
select: dynamic(() => import('../stories/select.component.mdx')),
switch: dynamic(() => import('../stories/switch.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 5ef28810665..460afde6778 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -315,6 +315,11 @@ import {
Placeholder as SelectComponentPlaceholder,
} from '../stories/select.component.stories';
import { meta as selectMeta } from '../stories/select.stories';
+import {
+ Default as SeparatorDefault,
+ Labelled as SeparatorLabelled,
+ meta as separatorMeta,
+} from '../stories/separator.stories';
import {
Elements as SkeletonElements,
HoldSpace as SkeletonHoldSpace,
@@ -903,6 +908,12 @@ const segmentedControlModule: StoryModule = {
Disabled: SegmentedControlDisabled,
};
+const separatorModule: StoryModule = {
+ meta: separatorMeta,
+ Default: SeparatorDefault,
+ Labelled: SeparatorLabelled,
+};
+
const switchModule: StoryModule = {
meta: switchMeta,
Default: SwitchDefault,
@@ -1179,6 +1190,7 @@ export const registry: StoryModule[] = [
revealModule,
sectionModule,
segmentedControlModule,
+ separatorModule,
selectComponentModule,
switchModule,
tableModule,
diff --git a/packages/swingset/src/stories/separator.mdx b/packages/swingset/src/stories/separator.mdx
new file mode 100644
index 00000000000..af88be8a4c9
--- /dev/null
+++ b/packages/swingset/src/stories/separator.mdx
@@ -0,0 +1,46 @@
+import * as SeparatorStories from './separator.stories';
+
+# Separator
+
+Separator draws a horizontal line between blocks of content. Without children it is a plain 1px line. With children it is a labelled divider, with a line on each side of the label. It renders a `div` with `role='separator'`; the label is presentational and is not announced by screen readers.
+
+## Example
+
+
+
+## Usage
+
+```tsx
+import { Separator } from '@clerk/mosaic/components/separator';
+
+
+or
+```
+
+
+
+## Parts
+
+| Part | Element | Class | Description |
+| ----------- | ------- | --------------- | --------------------------------------------------------------- |
+| `Separator` | `div` | `.cl-separator` | The line, or with children the label and a line on either side. |
+
+`Separator` accepts `render` and `xstyle` and forwards its ref.
+
+## Styling
+
+`data-labelled` is set when the separator has children. The labelled lines are the root's `::before` and `::after`. Both forms read `--cl-color-border` for the line; the label reads `--cl-color-foreground-secondary`.
+
+```css
+.cl-separator:not([data-labelled]),
+.cl-separator::before,
+.cl-separator::after {
+ background-color: var(--cl-color-border-subtle);
+}
+```
diff --git a/packages/swingset/src/stories/separator.stories.tsx b/packages/swingset/src/stories/separator.stories.tsx
new file mode 100644
index 00000000000..8f238b69d81
--- /dev/null
+++ b/packages/swingset/src/stories/separator.stories.tsx
@@ -0,0 +1,20 @@
+import { Separator } from '@clerk/mosaic/components/separator';
+
+import type { StoryMeta } from '@/lib/types';
+
+export { default as __source } from './separator.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Components',
+ status: 'stable',
+ title: 'Separator',
+ source: 'packages/mosaic/src/components/separator/separator.tsx',
+};
+
+export function Default() {
+ return ;
+}
+
+export function Labelled() {
+ return or;
+}