Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/mosaic-experimental-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@clerk/nextjs': minor
'@clerk/react': minor
'@clerk/ui': minor
---

Add an experimental subpath for Mosaic components that mount directly in your app's tree rather than being rendered by clerk-js. `UserButton` is the first one. It reads Clerk through hooks, so a `ClerkProvider` above it is all it needs:

```tsx
import { UserButton } from '@clerk/nextjs/experimental/mosaic';
```

Pair it with the stylesheet, which carries the design tokens and every component rule:

```css
@import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk);
```

The surface and the components behind it will change without a major version while they are experimental.

In `@clerk/ui`, the Mosaic stylesheet moves from `@clerk/ui/styles.css` to `@clerk/ui/experimental/mosaic/styles.css` to sit alongside the components it styles. Update the import if you were using it.
4 changes: 2 additions & 2 deletions .claude/skills/mosaic/references/stylex.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,8 +621,8 @@ export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … }

- **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the
`styles/index.ts` barrel into `dist-mosaic/styles.css`, exported as
`@clerk/ui/styles.css`. Consumers choose the cascade layer at import:
`@import '@clerk/ui/styles.css' layer(components)`.
`@clerk/ui/experimental/mosaic/styles.css`. Consumers choose the cascade layer at import:
`@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`.
- **Swingset** (source-consumed): `@stylexjs/unplugin/webpack` in `next.config`
transforms StyleX **JS only** (calls → static atoms; SWC/Emotion untouched);
`@stylexjs/postcss-plugin` extracts the **CSS** by replacing `@stylex;` in
Expand Down
11 changes: 9 additions & 2 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
"import": "./dist/esm/experimental.js",
"require": "./dist/cjs/experimental.js"
},
"./experimental/mosaic": {
"types": "./dist/types/experimental/mosaic.d.ts",
"import": "./dist/esm/experimental/mosaic.js",
"require": "./dist/cjs/experimental/mosaic.js"
},
"./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css",
"./legacy": {
"types": "./dist/types/legacy.d.ts",
"import": "./dist/esm/legacy.js",
Expand All @@ -70,15 +76,15 @@
"webhooks"
],
"scripts": {
"build": "pnpm clean && tsup",
"build": "pnpm clean && tsup && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css",
"build:declarations": "tsc -p tsconfig.declarations.json",
"clean": "rimraf ./dist",
"dev": "tsup --watch",
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src",
"lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax",
"lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16 --ignore-rules unexpected-module-syntax",
"lint:publint": "publint",
"test": "vitest run",
"test:watch": "vitest watch"
Expand All @@ -91,6 +97,7 @@
"tslib": "catalog:repo"
},
"devDependencies": {
"@clerk/ui": "workspace:*",
"crypto-es": "^2.1.0",
"next": "15.5.19"
},
Expand Down
16 changes: 16 additions & 0 deletions packages/nextjs/src/experimental/mosaic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

/**
* Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They
* read Clerk via hooks, so a `ClerkProvider` above them is all they need.
*
* Pair with the stylesheet, which carries the design tokens and every component rule:
*
* ```css
* @import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk);
* ```
*
* @experimental The surface and the components behind it are subject to change.
*/
export { UserButton } from '@clerk/react/experimental/mosaic';
export type { UserButtonProps } from '@clerk/react/experimental/mosaic';
17 changes: 14 additions & 3 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@
"default": "./dist/experimental.cjs"
}
},
"./experimental/mosaic": {
"import": {
"types": "./dist/experimental/mosaic.d.mts",
"default": "./dist/experimental/mosaic.mjs"
},
"require": {
"types": "./dist/experimental/mosaic.d.cts",
"default": "./dist/experimental/mosaic.cjs"
}
},
"./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css",
"./legacy": {
"import": {
"types": "./dist/legacy.d.mts",
Expand All @@ -88,25 +99,25 @@
"dist"
],
"scripts": {
"build": "tsdown",
"build": "tsdown && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css",
"clean": "rimraf ./dist",
"dev": "tsdown --watch",
"dev:pub": "pnpm dev --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src",
"lint:attw": "attw --pack . --profile node16",
"lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16",
"lint:publint": "publint",
"test": "vitest run",
"test:watch": "vitest watch"
},
"dependencies": {
"@clerk/shared": "workspace:^",
"@clerk/ui": "workspace:^",
"tslib": "catalog:repo"
},
"devDependencies": {
"@clerk/localizations": "workspace:*",
"@clerk/ui": "workspace:*",
"@types/semver": "^7.7.1",
"semver": "^7.8.5",
"yaml": "^2.9.0"
Expand Down
14 changes: 14 additions & 0 deletions packages/react/src/experimental/mosaic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They
* read Clerk via `@clerk/shared/react` hooks, so a `ClerkProvider` above them is all they need.
*
* Pair with the stylesheet, which carries the design tokens and every component rule:
*
* ```css
* @import '@clerk/react/experimental/mosaic/styles.css' layer(clerk);
* ```
*
* @experimental The surface and the components behind it are subject to change.
*/
export { UserButton } from '@clerk/ui/experimental/mosaic';
export type { UserButtonProps } from '@clerk/ui/experimental/mosaic';
1 change: 1 addition & 0 deletions packages/react/tsdown.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default defineConfig((overrideOptions: Options) => {
internal: 'src/internal.ts',
errors: 'src/errors.ts',
experimental: 'src/experimental.ts',
'experimental/mosaic': 'src/experimental/mosaic.ts',
legacy: 'src/legacy.ts',
types: 'src/types/index.ts',
},
Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/stories/menu.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ const [open, setOpen] = useState(false);
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`:
over `@clerk/ui/experimental/mosaic/styles.css`:

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

@layer overrides {
.cl-menu-popup {
Expand Down
4 changes: 2 additions & 2 deletions packages/swingset/src/stories/popover.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ them through your own typography (`Heading`, `Text`) inside the surface.
Unlike the slot-recipe components, the Mosaic popover is themed with **StyleX**. Each styled part
carries a stable `.cl-<slot>` class (the slots in the table 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 layer that wins over `@clerk/ui/experimental/mosaic/styles.css`:

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

@layer overrides {
.cl-popover-popup[data-size='lg'] {
Expand Down
12 changes: 9 additions & 3 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@
"default": "./dist/experimental/index.js"
},
"./themes/shadcn.css": "./dist/themes/shadcn.css",
"./styles.css": {
"./experimental/mosaic": {
"types": "./dist-mosaic/index.d.ts",
"import": "./dist-mosaic/index.js",
"default": "./dist-mosaic/index.js"
},
"./experimental/mosaic/styles.css": {
"types": "./styles.css.d.ts",
"default": "./dist-mosaic/styles.css"
},
Expand All @@ -81,14 +86,15 @@
"register"
],
"scripts": {
"build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm type-check",
"build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm check:no-emotion && pnpm type-check",
"build:analyze": "rspack build --config rspack.config.js --env production --env analyze",
"build:esm": "tsdown",
"build:mosaic": "tsdown --config tsdown.mosaic.config.mts",
"build:rsdoctor": "RSDOCTOR=true rspack build --config rspack.config.js --env production",
"build:umd": "rspack build --config rspack.config.js --env production",
"bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json",
"bundlewatch:fix": "node bundlewatch-fix.mjs",
"check:no-emotion": "node scripts/check-mosaic-emotion-free.mjs",
"check:no-rhc": "node ../../scripts/search-for-rhc.mjs directory dist/no-rhc",
"clean": "rimraf ./dist",
"dev": "rspack serve --config rspack.config.js",
Expand Down Expand Up @@ -116,7 +122,6 @@
"@solana/wallet-adapter-base": "catalog:module-manager",
"@solana/wallet-adapter-react": "catalog:module-manager",
"@solana/wallet-standard": "catalog:module-manager",
"@stylexjs/stylex": "0.19.0",
"@swc/helpers": "catalog:repo",
"copy-to-clipboard": "3.3.3",
"core-js": "catalog:repo",
Expand All @@ -135,6 +140,7 @@
"@rspack/plugin-react-refresh": "catalog:rspack",
"@stylexjs/eslint-plugin": "0.19.0",
"@stylexjs/rollup-plugin": "0.19.0",
"@stylexjs/stylex": "0.19.0",
"@stylexjs/unplugin": "0.19.0",
"@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^6.5.1",
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/scripts/check-mosaic-emotion-free.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node

/**
* The `build:mosaic` entry is published as `@clerk/ui/experimental/mosaic` and mounted directly in
* host apps, so it must stay Emotion-free: pulling `@emotion/react` in ships a second styling
* runtime to every consumer. Nothing about the barrel enforces that — one legacy component reached
* from the graph (an `sx` prop, a `Box`, a `keyframes`) drags it back in silently. This fails the
* build instead.
*/

import { readFileSync } from 'node:fs';

const BUNDLE = new URL('../dist-mosaic/index.js', import.meta.url);

const source = readFileSync(BUNDLE, 'utf8');
const offenders = source.split('\n').filter(line => line.includes('@emotion'));

if (offenders.length > 0) {
console.error(`Found Emotion in the Mosaic build output (dist-mosaic/index.js):\n${offenders.join('\n')}`);
process.exit(1);
}

console.log('✅ No Emotion found in the Mosaic build output');
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/components/menu/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from './menu';
3 changes: 0 additions & 3 deletions packages/ui/src/mosaic/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type {
MenuItemProps as PrimitiveMenuItemProps,
MenuPopupProps,
MenuPortalProps,
MenuProps,
MenuSeparatorProps,
} from '@clerk/headless/menu';
import { Menu as Primitive } from '@clerk/headless/menu';
Expand All @@ -16,8 +15,6 @@ import { Icon } from '../icon';
import { reset } from '../reset.styles';
import { styles } from './menu.styles';

export type { MenuProps, MenuSeparatorProps };

export type MenuTriggerProps = MosaicComponentProps<'button'>;

/**
Expand Down
1 change: 0 additions & 1 deletion packages/ui/src/mosaic/components/popover/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export type {
PopoverCloseProps,
PopoverDescriptionProps,
PopoverPopupProps,
PopoverRootProps,
PopoverSize,
PopoverTitleProps,
PopoverTriggerProps,
Expand Down
3 changes: 0 additions & 3 deletions packages/ui/src/mosaic/components/popover/popover.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { PopoverProps as HeadlessPopoverProps } from '@clerk/headless/popover';
import { Popover as Primitive } from '@clerk/headless/popover';
import * as stylex from '@stylexjs/stylex';
import React from 'react';
Expand All @@ -10,8 +9,6 @@ import { sizes, styles } from './popover.styles';

export type PopoverSize = 'sm' | 'md' | 'lg';

export type PopoverRootProps = HeadlessPopoverProps;

/**
* The headless parts type their props (and the `render` callback's argument) against
* the raw tag props, which carry the non-standard HTML `color` attribute typed
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/mosaic/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Public entry for `@clerk/ui/experimental/mosaic`. The side-effect import keeps every migrated
// component in the StyleX graph so the emitted `styles.css` stays complete, without making them API:
// `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too.
import './styles';

export { UserButton } from './user-button/user-button';
export type { UserButtonProps } from './user-button/user-button';
12 changes: 4 additions & 8 deletions packages/ui/src/mosaic/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

export type { MosaicComponentProps, MosaicElementProps } from '../props';

export { UserButton } from '../user-button/user-button';
export type { UserButtonProps } from '../user-button/user-button';

export { Avatar } from '../components/avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar';
export { Badge } from '../components/badge';
Expand All @@ -21,13 +24,7 @@ export type { IconProps } from '../components/icon';
export { Item } from '../components/item';
export type { ItemProps } from '../components/item';
export { Menu } from '../components/menu';
export type {
MenuContentProps,
MenuItemProps,
MenuProps,
MenuSeparatorProps,
MenuTriggerProps,
} from '../components/menu';
export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from '../components/menu';
export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area';
export type { ScrollAreaGutter } from '../components/scroll-area';
export { Spinner } from '../components/spinner';
Expand All @@ -40,7 +37,6 @@ export type {
PopoverCloseProps,
PopoverDescriptionProps,
PopoverPopupProps,
PopoverRootProps,
PopoverSize,
PopoverTitleProps,
PopoverTriggerProps,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/styles.css.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Type stub so `import '@clerk/ui/styles.css'` type-checks. The StyleX build emits
// Type stub so `import '@clerk/ui/experimental/mosaic/styles.css'` type-checks. The StyleX build emits
// the real stylesheet to dist-mosaic/styles.css; this side-effect import has no value.
export {};
4 changes: 3 additions & 1 deletion packages/ui/tsconfig.mosaic.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
// declaration bundle. Its published `dist/*.d.ts` are re-export barrels that
// rolldown-plugin-dts can't follow when inlining, so building types against source
// (the monorepo default) lets Mosaic components import headless types directly.
// `utils` and `hooks` sit at the source root; every other subpath is a primitive.
"@clerk/headless/utils": ["../headless/src/utils/index.ts"],
"@clerk/headless/*": ["../headless/src/*"],
"@clerk/headless/hooks": ["../headless/src/hooks/index.ts"],
"@clerk/headless/*": ["../headless/src/primitives/*"],
// Preserve the base config's test-only aliases (extends replaces `paths` wholesale).
"@/core/*": ["../clerk-js/src/core/*"],
"@/*": ["./src/*"],
Expand Down
Loading
Loading