Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): make cva variants optional and add cx sugar by alexcarpenter · Pull Request #8823 · clerk/javascript · GitHub
Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
---
++Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand DownExpand Up@@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand DownExpand Up@@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All@@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All@@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading