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.
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-custom-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
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';
15 changes: 13 additions & 2 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,14 +99,14 @@
"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"
Expand Down
16 changes: 16 additions & 0 deletions packages/react/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 `@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';
8 changes: 7 additions & 1 deletion 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 All @@ -76,7 +77,12 @@ export default defineConfig((overrideOptions: Options) => {
// Bundle @clerk/ui/register inline at build time so consumers don't need
// @clerk/ui as a dependency. The registration code sets up globalThis.__clerkSharedModules
// to enable @clerk/ui's shared variant to use the host app's React.
noExternal: ['@clerk/ui/register'],
//
// The Mosaic entry is inlined for the same reason: left external, the re-export resolves from
// the consumer's tree at runtime, which makes @clerk/ui a dependency and installs its whole
// graph (Emotion, the Solana wallet adapters, ...) for every consumer, Mosaic or not. Its build
// already bundles everything except React and @clerk/shared, both of which we ship anyway.
noExternal: ['@clerk/ui/register', '@clerk/ui/experimental/mosaic'],
define: {
PACKAGE_NAME: `"${pkgJson.name}"`,
PACKAGE_VERSION: `"${pkgJson.version}"`,
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
4 changes: 3 additions & 1 deletion packages/ui/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
{ "path": "./dist/op-plans-page*.js", "maxSize": "3KB" },
{ "path": "./dist/statement-page*.js", "maxSize": "5KB" },
{ "path": "./dist/payment-attempt-page*.js", "maxSize": "4KB" },
{ "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" }
{ "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" },
{ "path": "./dist-mosaic/styles.css", "maxSize": "8KB" },
{ "path": "./dist-mosaic/index.js", "maxSize": "88KB" }
]
}
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
Loading
Loading