diff --git a/.changeset/busy-impalas-wink.md b/.changeset/busy-impalas-wink.md
new file mode 100644
index 00000000000..eb4502e601a
--- /dev/null
+++ b/.changeset/busy-impalas-wink.md
@@ -0,0 +1,7 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/themes': minor
+'@clerk/types': minor
+---
+
+TODO
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index dc2341dcfb5..24e67cc466e 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -6,7 +6,7 @@
{ "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "113KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "118KB" },
- { "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
+ { "path": "./dist/vendors*.js", "maxSize": "40.6KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
{ "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" },
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
diff --git a/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx b/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx
index dfedab25078..4d6f5b075dc 100644
--- a/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx
+++ b/packages/clerk-js/src/ui/customizables/AppearanceContext.tsx
@@ -1,4 +1,7 @@
import { createContextAndHook, useDeepEqualMemo } from '@clerk/shared/react';
+import type { BaseTheme, BaseThemeTaggedType } from '@clerk/types';
+// eslint-disable-next-line no-restricted-imports
+import { css, Global } from '@emotion/react';
import React from 'react';
import type { AppearanceCascade, ParsedAppearance } from './parseAppearance';
@@ -17,7 +20,34 @@ const AppearanceProvider = (props: AppearanceProviderProps) => {
return { value };
}, [props.appearance, props.globalAppearance]);
- return {props.children};
+ // Extract global CSS from theme if it exists
+ const getGlobalCss = (theme: BaseTheme | BaseTheme[] | undefined) => {
+ if (
+ typeof theme === 'object' &&
+ !Array.isArray(theme) &&
+ '__type' in theme &&
+ theme.__type === 'prebuilt_appearance'
+ ) {
+ // Cast to the specific type that includes __internal_globalCss
+ return (theme as BaseThemeTaggedType & { __internal_globalCss?: string }).__internal_globalCss;
+ }
+ return null;
+ };
+
+ const globalCss = getGlobalCss(props.appearance?.theme) || getGlobalCss(props.globalAppearance?.theme);
+
+ return (
+
+ {globalCss && (
+
+ )}
+ {props.children}
+
+ );
};
export { AppearanceProvider, useAppearance };
diff --git a/packages/themes/package.json b/packages/themes/package.json
index 0282576c861..1318c507612 100644
--- a/packages/themes/package.json
+++ b/packages/themes/package.json
@@ -45,14 +45,17 @@
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src",
- "lint:attw": "attw --pack . --exclude-entrypoints shadcn.css --profile node16"
+ "lint:attw": "attw --pack . --exclude-entrypoints shadcn.css --profile node16",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"@clerk/types": "workspace:^",
"tslib": "catalog:repo"
},
"devDependencies": {
- "tsup": "catalog:repo"
+ "tsup": "catalog:repo",
+ "vitest": "catalog:repo"
},
"engines": {
"node": ">=18.17.0"
diff --git a/packages/themes/src/__tests__/createTheme.test.ts b/packages/themes/src/__tests__/createTheme.test.ts
new file mode 100644
index 00000000000..507581b0aa8
--- /dev/null
+++ b/packages/themes/src/__tests__/createTheme.test.ts
@@ -0,0 +1,1079 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ convertTuplesToCssVariables,
+ createThemeFactory,
+ createThemeObject,
+ experimental_createTheme,
+ mergeThemeConfigurations,
+ normalizeDarkModeSelector,
+ resolveBaseTheme,
+ resolveElementsConfiguration,
+ toKebabCase,
+ transformSelectorForNesting,
+} from '../createTheme';
+
+describe('Helper Functions', () => {
+ describe('toKebabCase', () => {
+ it('should convert camelCase to kebab-case', () => {
+ expect(toKebabCase('colorBackground')).toBe('color-background');
+ expect(toKebabCase('colorPrimaryForeground')).toBe('color-primary-foreground');
+ expect(toKebabCase('fontFamily')).toBe('font-family');
+ expect(toKebabCase('backgroundColor')).toBe('background-color');
+ });
+
+ it('should handle single words', () => {
+ expect(toKebabCase('color')).toBe('color');
+ });
+
+ it('should handle already lowercase strings', () => {
+ expect(toKebabCase('background')).toBe('background');
+ });
+
+ it('should handle consecutive capitals', () => {
+ expect(toKebabCase('XMLParser')).toBe('x-m-l-parser');
+ });
+
+ it('should not add leading hyphen for strings starting with uppercase', () => {
+ expect(toKebabCase('ColorBackground')).toBe('color-background');
+ expect(toKebabCase('Primary')).toBe('primary');
+ });
+ });
+
+ describe('transformSelectorForNesting', () => {
+ it('should transform class selectors to use CSS nesting', () => {
+ expect(transformSelectorForNesting('.dark')).toBe('.dark &');
+ expect(transformSelectorForNesting('.theme-dark')).toBe('.theme-dark &');
+ expect(transformSelectorForNesting('.custom-selector')).toBe('.custom-selector &');
+ });
+
+ it('should transform ID selectors to use CSS nesting', () => {
+ expect(transformSelectorForNesting('#dark')).toBe('#dark &');
+ expect(transformSelectorForNesting('#theme-dark')).toBe('#theme-dark &');
+ expect(transformSelectorForNesting('#custom-selector')).toBe('#custom-selector &');
+ });
+
+ it('should not transform selectors that already contain &', () => {
+ expect(transformSelectorForNesting('.dark &')).toBe('.dark &');
+ expect(transformSelectorForNesting('#theme &')).toBe('#theme &');
+ expect(transformSelectorForNesting('.theme & .dark')).toBe('.theme & .dark');
+ });
+
+ it('should not transform media queries', () => {
+ expect(transformSelectorForNesting('@media (prefers-color-scheme: dark)')).toBe(
+ '@media (prefers-color-scheme: dark)',
+ );
+ expect(transformSelectorForNesting('@media screen and (max-width: 768px)')).toBe(
+ '@media screen and (max-width: 768px)',
+ );
+ });
+
+ it('should not transform non-class/non-ID selectors', () => {
+ expect(transformSelectorForNesting('[data-theme="dark"]')).toBe('[data-theme="dark"]');
+ expect(transformSelectorForNesting('html')).toBe('html');
+ expect(transformSelectorForNesting('body')).toBe('body');
+ });
+
+ it('should handle complex class selectors', () => {
+ expect(transformSelectorForNesting('.dark.active')).toBe('.dark.active &');
+ expect(transformSelectorForNesting('.theme-dark .nested')).toBe('.theme-dark .nested &');
+ });
+
+ it('should handle complex ID selectors', () => {
+ expect(transformSelectorForNesting('#dark-theme')).toBe('#dark-theme &');
+ expect(transformSelectorForNesting('#theme .nested')).toBe('#theme .nested &');
+ });
+ });
+
+ describe('normalizeDarkModeSelector', () => {
+ it('should return provided selector when valid', () => {
+ expect(normalizeDarkModeSelector('.custom-dark')).toBe('.custom-dark');
+ expect(normalizeDarkModeSelector('[data-theme="dark"]')).toBe('[data-theme="dark"]');
+ expect(normalizeDarkModeSelector('.my-theme')).toBe('.my-theme');
+ });
+
+ it('should opt out by default when no selector provided', () => {
+ expect(normalizeDarkModeSelector(undefined)).toBeNull(); // Light-only by default
+ expect(normalizeDarkModeSelector('')).toBeNull(); // Empty string opts out
+ expect(normalizeDarkModeSelector(' ')).toBeNull(); // Whitespace-only opts out
+ });
+
+ it('should return null when false is passed to opt out', () => {
+ expect(normalizeDarkModeSelector(false)).toBeNull();
+ });
+
+ it('should trim whitespace', () => {
+ expect(normalizeDarkModeSelector(' .custom-dark ')).toBe('.custom-dark');
+ });
+ });
+
+ describe('convertTuplesToCssVariables', () => {
+ it('should process tuple variables and generate CSS with default selector', () => {
+ const variables = {
+ colorBackground: ['#ffffff', '#000000'],
+ colorPrimary: ['#blue', '#lightblue'],
+ fontFamily: 'Arial', // Non-tuple should pass through
+ };
+
+ const result = convertTuplesToCssVariables(variables, '.dark');
+
+ expect(result.processedVariables).toEqual({
+ colorBackground: 'var(--clerk-color-background)',
+ colorPrimary: 'var(--clerk-color-primary)',
+ fontFamily: 'Arial',
+ });
+
+ expect(result.cssString).toContain(':root {');
+ expect(result.cssString).toContain('--clerk-color-background: #ffffff;');
+ expect(result.cssString).toContain('--clerk-color-primary: #blue;');
+ expect(result.cssString).toContain('.dark {');
+ expect(result.cssString).toContain('--clerk-color-background: #000000;');
+ expect(result.cssString).toContain('--clerk-color-primary: #lightblue;');
+ });
+
+ it('should generate CSS with custom dark mode selector', () => {
+ const variables = {
+ colorBackground: ['#fff', '#000'],
+ };
+
+ const result = convertTuplesToCssVariables(variables, '[data-theme="dark"]');
+
+ expect(result.cssString).toContain('[data-theme="dark"] {');
+ expect(result.cssString).toContain('--clerk-color-background: #000;');
+ });
+
+ it('should require explicit media query selector', () => {
+ const variables = {
+ colorBackground: ['#fff', '#000'],
+ };
+
+ // Must be explicit - no fallbacks
+ const result = convertTuplesToCssVariables(variables, '@media (prefers-color-scheme: dark)');
+
+ expect(result.cssString).toContain('@media (prefers-color-scheme: dark)');
+ expect(result.cssString).toContain(':root {');
+ expect(result.processedVariables.colorBackground).toBe('var(--clerk-color-background)');
+ });
+
+ it('should handle media query selectors correctly', () => {
+ const variables = {
+ colorBackground: ['#fff', '#000'],
+ };
+
+ const result = convertTuplesToCssVariables(variables, '@media (prefers-color-scheme: dark)');
+
+ expect(result.cssString).toContain('@media (prefers-color-scheme: dark) {');
+ expect(result.cssString).toContain(' :root {'); // Check for nested :root
+ expect(result.cssString).toContain(' --clerk-color-background: #000;');
+ expect(result.cssString).toContain(' }'); // Closing :root
+ expect(result.cssString).toContain(' }'); // Closing media query
+ });
+
+ it('should opt out of CSS generation when darkModeSelector is null', () => {
+ const variables = {
+ colorBackground: ['#fff', '#000'],
+ colorPrimary: ['#blue', '#lightblue'],
+ colorSecondary: '#red', // Non-tuple should pass through
+ };
+
+ const result = convertTuplesToCssVariables(variables, null);
+
+ expect(result.processedVariables).toEqual({
+ colorBackground: '#fff', // Light value only
+ colorPrimary: '#blue', // Light value only
+ colorSecondary: '#red', // Non-tuple passes through
+ });
+ expect(result.cssString).toBeNull(); // No CSS generated
+ });
+
+ it('should handle empty variables', () => {
+ const result = convertTuplesToCssVariables({});
+ expect(result.processedVariables).toEqual({});
+ expect(result.cssString).toBeNull();
+ });
+
+ it('should handle non-tuple arrays gracefully', () => {
+ const variables = {
+ colorBackground: [], // Invalid tuple
+ colorPrimary: ['#blue'], // Single value
+ colorSecondary: ['#red', '#darkred'], // Valid tuple
+ };
+
+ const result = convertTuplesToCssVariables(variables, '.dark');
+
+ expect(result.processedVariables).toEqual({
+ colorBackground: [],
+ colorPrimary: ['#blue'],
+ colorSecondary: 'var(--clerk-color-secondary)',
+ });
+ expect(result.cssString).toContain('--clerk-color-secondary: #red;');
+ expect(result.cssString).toContain('--clerk-color-secondary: #darkred;');
+ });
+
+ it('should convert camelCase variable names to kebab-case CSS variables', () => {
+ const variables = {
+ colorPrimaryForeground: ['#000', '#fff'],
+ };
+
+ const result = convertTuplesToCssVariables(variables, '.dark');
+
+ expect(result.processedVariables.colorPrimaryForeground).toBe('var(--clerk-color-primary-foreground)');
+ expect(result.cssString).toContain('--clerk-color-primary-foreground: #000;');
+ expect(result.cssString).toContain('--clerk-color-primary-foreground: #fff;');
+ });
+ });
+
+ describe('resolveElementsConfiguration', () => {
+ it('should return undefined for no elements', () => {
+ expect(resolveElementsConfiguration(undefined, '.dark')).toBeUndefined();
+ });
+
+ it('should return static elements as-is', () => {
+ const elements = {
+ button: { backgroundColor: 'red' },
+ input: { borderColor: 'blue' },
+ };
+
+ const result = resolveElementsConfiguration(elements, '.dark');
+ expect(result).toEqual(elements);
+ });
+
+ it('should call function-based elements with transformed darkModeSelector', () => {
+ const elementsFn = vi.fn().mockReturnValue({
+ button: { backgroundColor: 'red' },
+ });
+
+ const result = resolveElementsConfiguration(elementsFn, '.custom-dark');
+
+ expect(elementsFn).toHaveBeenCalledWith('.custom-dark &'); // Now transformed with &
+ expect(result).toEqual({ button: { backgroundColor: 'red' } });
+ });
+
+ it('should handle function errors gracefully', () => {
+ const elementsFn = vi.fn().mockImplementation(() => {
+ throw new Error('Function error');
+ });
+
+ // Mock console.warn to avoid test output noise
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const result = resolveElementsConfiguration(elementsFn, '.dark');
+
+ expect(result).toBeUndefined();
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ '[Clerk Theme] Failed to call elements function with darkModeSelector:',
+ expect.any(Error),
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+
+ it('should return undefined when darkModeSelector is null (opted out)', () => {
+ const elementsFn = vi.fn().mockReturnValue({
+ button: { backgroundColor: 'red' },
+ });
+
+ const result = resolveElementsConfiguration(elementsFn, null);
+
+ expect(elementsFn).not.toHaveBeenCalled(); // Function should not be called when opted out
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('resolveBaseTheme', () => {
+ it('should return undefined for no baseTheme', () => {
+ expect(resolveBaseTheme(undefined)).toBeUndefined();
+ });
+
+ it('should return static theme as-is', () => {
+ const baseTheme = {
+ __type: 'prebuilt_appearance' as const,
+ variables: { colorPrimary: '#blue' },
+ };
+
+ const result = resolveBaseTheme(baseTheme);
+ expect(result).toBe(baseTheme);
+ });
+
+ it('should call factory function with options', () => {
+ const baseThemeFactory = vi.fn().mockReturnValue({
+ __type: 'prebuilt_appearance',
+ variables: { colorPrimary: '#green' },
+ });
+
+ const options = { darkModeSelector: '.custom' };
+ const result = resolveBaseTheme(baseThemeFactory as any, options);
+
+ expect(baseThemeFactory).toHaveBeenCalledWith(options);
+ expect(result).toEqual({
+ __type: 'prebuilt_appearance',
+ variables: { colorPrimary: '#green' },
+ });
+ });
+
+ it('should handle factory function errors gracefully', () => {
+ const baseThemeFactory = vi.fn().mockImplementation(() => {
+ throw new Error('Factory error');
+ });
+
+ // Mock console.warn
+ const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const result = resolveBaseTheme(baseThemeFactory as any);
+
+ expect(result).toBe(baseThemeFactory);
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ '[Clerk Theme] Failed to call baseTheme factory function:',
+ expect.any(Error),
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+ });
+
+ describe('mergeThemeConfigurations', () => {
+ it('should merge variables from base and child themes', () => {
+ const baseTheme = {
+ __type: 'prebuilt_appearance' as const,
+ variables: {
+ colorPrimary: '#base-blue',
+ colorBackground: '#base-white',
+ },
+ elements: {
+ button: { padding: '8px' },
+ },
+ };
+
+ const childConfig = {
+ variables: {
+ colorPrimary: '#child-red', // Override
+ colorSecondary: '#child-green', // Add new
+ },
+ elements: {
+ input: { margin: '4px' }, // Add new
+ },
+ };
+
+ const result = mergeThemeConfigurations(baseTheme, childConfig);
+
+ expect(result.mergedVariables).toEqual({
+ colorPrimary: '#child-red', // Overridden
+ colorBackground: '#base-white', // Inherited
+ colorSecondary: '#child-green', // Added
+ });
+
+ expect(result.mergedElements).toEqual({
+ button: { padding: '8px' }, // Inherited
+ input: { margin: '4px' }, // Added
+ });
+
+ expect(result.mergedConfig).toEqual({
+ __type: 'prebuilt_appearance',
+ variables: {
+ colorPrimary: '#child-red', // Override
+ colorSecondary: '#child-green', // Add new
+ },
+ elements: {
+ input: { margin: '4px' }, // Add new
+ },
+ });
+ });
+
+ it('should handle missing base theme', () => {
+ const childConfig = {
+ variables: { colorPrimary: '#red' },
+ elements: { button: { padding: '10px' } },
+ };
+
+ const result = mergeThemeConfigurations(undefined, childConfig);
+
+ expect(result.mergedVariables).toEqual({ colorPrimary: '#red' });
+ expect(result.mergedElements).toEqual({ button: { padding: '10px' } });
+ expect(result.mergedConfig).toEqual(childConfig);
+ });
+
+ it('should handle empty configurations', () => {
+ const result = mergeThemeConfigurations(undefined, {});
+
+ expect(result.mergedVariables).toBeUndefined();
+ expect(result.mergedElements).toBeUndefined();
+ expect(result.mergedConfig).toEqual({});
+ });
+ });
+
+ describe('createThemeObject', () => {
+ it('should create theme object with all properties', () => {
+ const config = { baseProperty: 'value' };
+ const variables = { colorPrimary: '#red' };
+ const elements = { button: { padding: '10px' } };
+ const globalCss = ':root { --color: red; }';
+
+ const result = createThemeObject(config, variables, elements, globalCss);
+
+ expect(result).toEqual({
+ baseProperty: 'value',
+ __type: 'prebuilt_appearance',
+ variables: { colorPrimary: '#red' },
+ elements: { button: { padding: '10px' } },
+ __internal_globalCss: ':root { --color: red; }',
+ });
+ });
+
+ it('should omit undefined properties', () => {
+ const config = { baseProperty: 'value' };
+
+ const result = createThemeObject(config, undefined, undefined, null);
+
+ expect(result).toEqual({
+ baseProperty: 'value',
+ __type: 'prebuilt_appearance',
+ });
+ expect(result).not.toHaveProperty('variables');
+ expect(result).not.toHaveProperty('elements');
+ expect(result).not.toHaveProperty('__internal_globalCss');
+ });
+ });
+
+ describe('createThemeFactory', () => {
+ it('should create a factory function that generates themes', () => {
+ const config = {
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ colorPrimary: '#blue',
+ },
+ elements: {
+ button: { padding: '10px' },
+ },
+ };
+
+ const factory = createThemeFactory(config);
+ const theme = factory();
+
+ expect(typeof factory).toBe('function');
+ expect(theme).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only by default
+ expect(theme.variables?.colorPrimary).toBe('#blue');
+ expect(theme.elements).toEqual({ button: { padding: '10px' } });
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS by default
+ });
+
+ it('should pass darkModeSelector to elements function', () => {
+ const elementsFn = vi.fn().mockReturnValue({ button: { color: 'red' } });
+ const config = { elements: elementsFn };
+
+ const factory = createThemeFactory(config);
+ const theme = factory({ darkModeSelector: '.custom-dark' });
+
+ expect(elementsFn).toHaveBeenCalledWith('.custom-dark &');
+ expect(theme.elements).toEqual({ button: { color: 'red' } });
+ });
+
+ it('should handle baseTheme with factory function', () => {
+ const baseThemeFactory = vi.fn().mockReturnValue({
+ __type: 'prebuilt_appearance',
+ variables: { colorPrimary: '#base-blue' },
+ });
+
+ const config = {
+ baseTheme: baseThemeFactory as any,
+ variables: { colorSecondary: '#red' },
+ };
+
+ const factory = createThemeFactory(config);
+ const options = { darkModeSelector: '.custom' };
+ const theme = factory(options);
+
+ expect(baseThemeFactory).toHaveBeenCalledWith(options);
+ expect(theme.variables?.colorPrimary).toBe('#base-blue');
+ expect(theme.variables?.colorSecondary).toBe('#red');
+ });
+ });
+});
+
+describe('experimental_createTheme', () => {
+ describe('Basic theme creation', () => {
+ it('should create a theme with basic properties', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ colorPrimary: '#ff0000',
+ colorBackground: '#ffffff',
+ },
+ });
+
+ expect(theme).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(theme.variables).toEqual({
+ colorPrimary: '#ff0000',
+ colorBackground: '#ffffff',
+ });
+ });
+
+ it('should create a theme with static elements', () => {
+ const theme = experimental_createTheme({
+ elements: {
+ button: {
+ backgroundColor: 'blue',
+ },
+ },
+ });
+
+ expect(theme).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(theme.elements).toEqual({
+ button: {
+ backgroundColor: 'blue',
+ },
+ });
+ });
+ });
+
+ describe('Factory function behavior', () => {
+ it('should return a hybrid object that works as BaseTheme', () => {
+ const themeFactory = experimental_createTheme({
+ variables: { colorPrimary: '#ff0000' },
+ });
+
+ // Should work as BaseTheme directly
+ expect(themeFactory).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(themeFactory.variables).toEqual({ colorPrimary: '#ff0000' });
+ });
+
+ it('should be callable as a factory function', () => {
+ const themeFactory = experimental_createTheme({
+ variables: { colorPrimary: '#ff0000' },
+ });
+
+ // Should be callable
+ expect(typeof themeFactory).toBe('function');
+
+ const themeInstance = themeFactory();
+ expect(themeInstance).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(themeInstance.variables).toEqual({ colorPrimary: '#ff0000' });
+ });
+
+ it('should accept darkModeSelector option when called as factory', () => {
+ const themeFactory = experimental_createTheme({
+ variables: { colorPrimary: '#ff0000' },
+ });
+
+ const themeInstance = themeFactory({ darkModeSelector: '.my-dark' });
+ expect(themeInstance).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(themeInstance.variables).toEqual({ colorPrimary: '#ff0000' });
+ });
+ });
+
+ describe('darkModeSelector with elements callback', () => {
+ it('should not call elements function by default (light-only theme)', () => {
+ const elementsFn = vi.fn().mockReturnValue({
+ button: { backgroundColor: 'red' },
+ });
+
+ const themeFactory = experimental_createTheme({
+ elements: elementsFn,
+ });
+
+ // When accessed as BaseTheme (default behavior) - should be light-only
+ expect(elementsFn).not.toHaveBeenCalled(); // No dark mode by default
+ expect(themeFactory.elements).toBeUndefined(); // No elements without dark mode
+ });
+
+ it('should call elements function with custom darkModeSelector', () => {
+ const elementsFn = vi.fn().mockReturnValue({
+ button: { backgroundColor: 'blue' },
+ });
+
+ const themeFactory = experimental_createTheme({
+ elements: elementsFn,
+ });
+
+ // Reset the mock since it was called during theme creation
+ elementsFn.mockClear();
+
+ // When called as factory with custom selector
+ const themeInstance = themeFactory({ darkModeSelector: '.custom-dark' });
+
+ expect(elementsFn).toHaveBeenCalledWith('.custom-dark &');
+ expect(themeInstance.elements).toEqual({
+ button: { backgroundColor: 'blue' },
+ });
+ });
+
+ it('should use darkModeSelector in CSS selectors', () => {
+ const themeFactory = experimental_createTheme({
+ elements: (darkModeSelector: string) => ({
+ button: {
+ backgroundColor: 'red',
+ [darkModeSelector]: {
+ backgroundColor: 'blue',
+ },
+ },
+ }),
+ });
+
+ const themeInstance = themeFactory({ darkModeSelector: '.my-dark-mode' });
+
+ expect(themeInstance.elements).toEqual({
+ button: {
+ backgroundColor: 'red',
+ '.my-dark-mode &': {
+ backgroundColor: 'blue',
+ },
+ },
+ });
+ });
+
+ it('should handle complex selectors with darkModeSelector', () => {
+ const themeFactory = experimental_createTheme({
+ elements: (darkModeSelector: string) => ({
+ button: {
+ backgroundColor: 'red',
+ [darkModeSelector]: {
+ backgroundColor: 'blue',
+ },
+ [`${darkModeSelector}:hover`]: {
+ backgroundColor: 'green',
+ },
+ },
+ }),
+ });
+
+ const themeInstance = themeFactory({ darkModeSelector: '.dark' });
+
+ expect(themeInstance.elements).toEqual({
+ button: {
+ backgroundColor: 'red',
+ '.dark &': {
+ backgroundColor: 'blue',
+ },
+ '.dark &:hover': {
+ backgroundColor: 'green',
+ },
+ },
+ });
+ });
+ });
+
+ describe('baseTheme inheritance', () => {
+ it('should merge variables from baseTheme', () => {
+ const baseTheme = experimental_createTheme({
+ variables: {
+ colorPrimary: '#base-primary',
+ colorBackground: '#base-bg',
+ },
+ });
+
+ const childTheme = experimental_createTheme({
+ baseTheme,
+ variables: {
+ colorPrimary: '#child-primary', // Should override
+ },
+ });
+
+ expect(childTheme.variables).toEqual({
+ colorPrimary: '#child-primary', // Overridden
+ colorBackground: '#base-bg', // Inherited
+ });
+ });
+
+ it('should merge elements from baseTheme', () => {
+ const baseTheme = experimental_createTheme({
+ elements: {
+ button: { backgroundColor: 'base-color' },
+ input: { borderColor: 'base-border' },
+ },
+ });
+
+ const childTheme = experimental_createTheme({
+ baseTheme,
+ elements: {
+ button: { backgroundColor: 'child-color' }, // Should override
+ },
+ });
+
+ expect(childTheme.elements).toEqual({
+ button: { backgroundColor: 'child-color' }, // Overridden
+ input: { borderColor: 'base-border' }, // Inherited
+ });
+ });
+
+ it('should work with baseTheme and darkModeSelector elements callback', () => {
+ const baseTheme = experimental_createTheme({
+ variables: { colorPrimary: '#base' },
+ elements: { input: { borderColor: 'gray' } },
+ });
+
+ const childTheme = experimental_createTheme({
+ baseTheme,
+ elements: (darkModeSelector: string) => ({
+ button: {
+ backgroundColor: 'red',
+ [darkModeSelector]: { backgroundColor: 'blue' },
+ },
+ }),
+ });
+
+ expect(childTheme.variables).toEqual({ colorPrimary: '#base' });
+ expect(childTheme.elements).toEqual({
+ input: { borderColor: 'gray' }, // From base (static elements)
+ // Button elements function not called by default (no dark mode)
+ });
+ });
+ });
+
+ describe('Backwards compatibility', () => {
+ it('should work with existing theme usage patterns', () => {
+ // Simulate existing usage
+ const myTheme = experimental_createTheme({
+ variables: { colorPrimary: '#legacy' },
+ elements: { button: { padding: '10px' } },
+ });
+
+ // Should work as BaseTheme directly (backwards compatibility)
+ const appearance = { theme: myTheme };
+
+ expect(appearance.theme).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(appearance.theme.variables).toEqual({ colorPrimary: '#legacy' });
+ expect(appearance.theme.elements).toEqual({ button: { padding: '10px' } });
+ });
+
+ it('should maintain type compatibility', () => {
+ const theme = experimental_createTheme({
+ variables: { colorPrimary: '#test' },
+ });
+
+ // These should all work without TypeScript errors
+ const directUsage = theme;
+ const factoryUsage = theme();
+ const withOptions = theme({ darkModeSelector: '.custom' });
+
+ expect(directUsage.__type).toBe('prebuilt_appearance');
+ expect(factoryUsage.__type).toBe('prebuilt_appearance');
+ expect(withOptions.__type).toBe('prebuilt_appearance');
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle empty theme configuration', () => {
+ const theme = experimental_createTheme({});
+
+ expect(theme).toHaveProperty('__type', 'prebuilt_appearance');
+ expect(theme.variables).toBeUndefined();
+ expect(theme.elements).toBeUndefined();
+ });
+
+ it('should handle undefined darkModeSelector (opt out)', () => {
+ const elementsFn = vi.fn().mockReturnValue({ button: {} });
+
+ const themeFactory = experimental_createTheme({
+ elements: elementsFn,
+ });
+
+ elementsFn.mockClear();
+ const themeInstance = themeFactory({ darkModeSelector: undefined });
+
+ expect(elementsFn).not.toHaveBeenCalled(); // Should opt out by default
+ expect(themeInstance.elements).toBeUndefined(); // No elements when opted out
+ });
+
+ it('should handle empty string darkModeSelector', () => {
+ const elementsFn = vi.fn().mockReturnValue({ button: {} });
+
+ const themeFactory = experimental_createTheme({
+ elements: elementsFn,
+ });
+
+ elementsFn.mockClear();
+ const themeInstance = themeFactory({ darkModeSelector: '' });
+
+ expect(elementsFn).not.toHaveBeenCalled(); // Should opt out for empty string
+ expect(themeInstance.elements).toBeUndefined(); // No elements when opted out
+ });
+ });
+
+ describe('Variables with tuple values', () => {
+ it('should convert tuple values to light-only by default', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ colorPrimary: ['#blue', '#lightblue'],
+ },
+ });
+
+ // By default, tuples should use light values only (no dark mode)
+ expect(theme.variables?.colorBackground).toBe('#ffffff');
+ expect(theme.variables?.colorPrimary).toBe('#blue');
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS generated by default
+ });
+
+ it('should handle custom darkModeSelector with tuple values', () => {
+ const themeFactory = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ },
+ });
+
+ const theme = themeFactory({ darkModeSelector: '.my-dark-theme' });
+
+ expect(theme.variables?.colorBackground).toBe('var(--clerk-color-background)');
+ expect((theme as any).__internal_globalCss).toContain('.my-dark-theme');
+ expect((theme as any).__internal_globalCss).toContain('--clerk-color-background: #000000;');
+ });
+
+ it('should convert camelCase tuple values to light values by default', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ colorPrimaryForeground: ['#000', '#fff'],
+ colorInputBackground: ['#f0f0f0', '#333'],
+ },
+ });
+
+ // By default, tuples should use light values only
+ expect(theme.variables?.colorPrimaryForeground).toBe('#000');
+ expect(theme.variables?.colorInputBackground).toBe('#f0f0f0');
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS by default
+
+ // Test explicit dark mode with kebab-case CSS variables
+ const darkTheme = experimental_createTheme({
+ variables: {
+ colorPrimaryForeground: ['#000', '#fff'],
+ colorInputBackground: ['#f0f0f0', '#333'],
+ },
+ })({ darkModeSelector: '.dark' });
+
+ expect(darkTheme.variables?.colorPrimaryForeground).toBe('var(--clerk-color-primary-foreground)');
+ expect(darkTheme.variables?.colorInputBackground).toBe('var(--clerk-color-input-background)');
+ expect((darkTheme as any).__internal_globalCss).toContain('--clerk-color-primary-foreground:');
+ expect((darkTheme as any).__internal_globalCss).toContain('--clerk-color-input-background:');
+ });
+
+ it('should preserve string variables as-is', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'], // tuple
+ colorPrimary: '#blue', // string
+ fontFamily: 'Arial', // string
+ },
+ });
+
+ // By default, tuples use light values only, strings preserved
+ expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value from tuple
+ expect(theme.variables?.colorPrimary).toBe('#blue');
+ expect(theme.variables?.fontFamily).toBe('Arial');
+ });
+
+ it('should work with baseTheme inheritance and tuple values', () => {
+ const baseTheme = experimental_createTheme({
+ variables: {
+ colorBackground: ['#f0f0f0', '#111'],
+ colorPrimary: '#base-blue',
+ },
+ });
+
+ const childTheme = experimental_createTheme({
+ baseTheme,
+ variables: {
+ colorBackground: ['#ffffff', '#000000'], // Override with tuple
+ colorSecondary: '#child-red', // Add new variable
+ },
+ });
+
+ // By default, tuples use light values only
+ expect(childTheme.variables?.colorBackground).toBe('#ffffff'); // Light value from tuple
+ expect(childTheme.variables?.colorPrimary).toBe('#base-blue'); // Inherited
+ expect(childTheme.variables?.colorSecondary).toBe('#child-red');
+ expect((childTheme as any).__internal_globalCss).toBeUndefined(); // No CSS by default
+ });
+
+ it('should not generate global CSS if no tuple values exist', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ colorPrimary: '#blue',
+ fontFamily: 'Arial',
+ },
+ });
+
+ expect((theme as any).__internal_globalCss).toBeUndefined();
+ });
+
+ it('should handle empty tuple arrays gracefully', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ colorBackground: [], // Invalid tuple
+ colorPrimary: ['#blue'], // Invalid tuple (only one value)
+ colorSecondary: ['#red', '#darkred'], // Valid tuple
+ } as any,
+ });
+
+ // Invalid tuples pass through as-is, valid tuples use light values by default
+ expect(theme.variables?.colorBackground).toEqual([]);
+ expect(theme.variables?.colorPrimary).toEqual(['#blue']);
+ expect(theme.variables?.colorSecondary).toBe('#red'); // Light value from valid tuple
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS by default
+ });
+
+ it('should require explicit darkModeSelector for CSS generation', () => {
+ // Test explicit media query selector
+ const mediaTheme = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ },
+ })({ darkModeSelector: '@media (prefers-color-scheme: dark)' });
+
+ expect((mediaTheme as any).__internal_globalCss).toContain('@media (prefers-color-scheme: dark)');
+ expect((mediaTheme as any).__internal_globalCss).toContain(':root {');
+ expect((mediaTheme as any).__internal_globalCss).toContain('--clerk-color-background: #000000');
+ expect(mediaTheme.variables?.colorBackground).toBe('var(--clerk-color-background)');
+
+ // Test explicit class selector
+ const classTheme = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ },
+ })({ darkModeSelector: '.dark' });
+
+ expect((classTheme as any).__internal_globalCss).toContain('.dark {');
+ expect((classTheme as any).__internal_globalCss).toContain('--clerk-color-background: #000000');
+ expect(classTheme.variables?.colorBackground).toBe('var(--clerk-color-background)');
+ });
+
+ it('should opt out of tuple CSS generation when darkModeSelector is false', () => {
+ const themeFactory = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ colorPrimary: ['#blue', '#lightblue'],
+ colorSecondary: '#red', // Non-tuple
+ },
+ });
+
+ const theme = themeFactory({ darkModeSelector: false });
+
+ expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only
+ expect(theme.variables?.colorPrimary).toBe('#blue'); // Light value only
+ expect(theme.variables?.colorSecondary).toBe('#red'); // Non-tuple passes through
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No CSS generated
+ });
+
+ it('should not process elements function when opted out of dark mode', () => {
+ const themeFactory = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ },
+ elements: darkModeSelector => ({
+ button: {
+ backgroundColor: 'red',
+ [darkModeSelector]: { backgroundColor: 'blue' },
+ },
+ }),
+ });
+
+ const theme = themeFactory({ darkModeSelector: false });
+
+ expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No tuple CSS
+ expect(theme.elements).toBeUndefined(); // No elements generated when opted out
+ });
+
+ it('should still work with static elements when opted out', () => {
+ const themeFactory = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#000000'],
+ },
+ elements: {
+ button: {
+ backgroundColor: 'red',
+ },
+ },
+ });
+
+ const theme = themeFactory({ darkModeSelector: false });
+
+ expect(theme.variables?.colorBackground).toBe('#ffffff'); // Light value only
+ expect((theme as any).__internal_globalCss).toBeUndefined(); // No tuple CSS
+ expect(theme.elements).toEqual({
+ button: { backgroundColor: 'red' }, // Static elements still work
+ });
+ });
+
+ it('should automatically detect and support all non-deprecated color variables', () => {
+ // This test verifies that our automatic color variable detection
+ // stays in sync with the Variables type from @clerk/types
+
+ // Test variables that should definitely work (known non-deprecated colors)
+ const knownColorVars = {
+ colorBackground: ['#fff', '#000'] as [string, string],
+ colorPrimary: ['#blue', '#darkblue'] as [string, string],
+ colorDanger: ['#red', '#darkred'] as [string, string],
+ colorSuccess: ['#green', '#darkgreen'] as [string, string],
+ colorNeutral: ['#gray', '#darkgray'] as [string, string],
+ };
+
+ // This should compile without errors due to automatic detection
+ const theme = experimental_createTheme({
+ variables: knownColorVars,
+ })({ darkModeSelector: '.dark' });
+
+ // All should be converted to CSS variables
+ expect(theme.variables?.colorBackground).toBe('var(--clerk-color-background)');
+ expect(theme.variables?.colorPrimary).toBe('var(--clerk-color-primary)');
+ expect(theme.variables?.colorDanger).toBe('var(--clerk-color-danger)');
+ expect(theme.variables?.colorSuccess).toBe('var(--clerk-color-success)');
+ expect(theme.variables?.colorNeutral).toBe('var(--clerk-color-neutral)');
+
+ // Should generate CSS for all
+ const css = (theme as any).__internal_globalCss;
+ expect(css).toContain('--clerk-color-background: #fff;');
+ expect(css).toContain('--clerk-color-background: #000;');
+ });
+
+ it('should support tuple values for all non-deprecated color variables', () => {
+ const theme = experimental_createTheme({
+ variables: {
+ // All non-deprecated color variables should support tuples
+ colorBackground: ['#ffffff', '#1a1a1a'],
+ colorForeground: ['#1a1a1a', '#ffffff'],
+ colorMuted: ['#f8f9fa', '#2d2d2d'],
+ colorMutedForeground: ['#6c757d', '#adb5bd'],
+ colorPrimary: ['#007bff', '#0d6efd'],
+ colorPrimaryForeground: ['#ffffff', '#000000'],
+ colorDanger: ['#dc3545', '#e74c3c'],
+ colorSuccess: ['#28a745', '#2ecc71'],
+ colorWarning: ['#ffc107', '#f39c12'],
+ colorNeutral: ['#6c757d', '#95a5a6'],
+ colorInput: ['#ffffff', '#2c2c2c'],
+ colorInputForeground: ['#1a1a1a', '#ffffff'],
+ colorShimmer: ['rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)'],
+ colorRing: ['rgba(0,123,255,0.25)', 'rgba(13,110,253,0.25)'],
+ colorShadow: ['rgba(0,0,0,0.175)', 'rgba(0,0,0,0.5)'],
+ colorBorder: ['#dee2e6', '#495057'],
+ colorModalBackdrop: ['rgba(0,0,0,0.5)', 'rgba(0,0,0,0.8)'],
+ },
+ })({ darkModeSelector: '.dark' });
+
+ // All tuple variables should be converted to CSS variables
+ expect(theme.variables?.colorBackground).toBe('var(--clerk-color-background)');
+ expect(theme.variables?.colorForeground).toBe('var(--clerk-color-foreground)');
+ expect(theme.variables?.colorMuted).toBe('var(--clerk-color-muted)');
+ expect(theme.variables?.colorMutedForeground).toBe('var(--clerk-color-muted-foreground)');
+ expect(theme.variables?.colorPrimary).toBe('var(--clerk-color-primary)');
+ expect(theme.variables?.colorPrimaryForeground).toBe('var(--clerk-color-primary-foreground)');
+ expect(theme.variables?.colorDanger).toBe('var(--clerk-color-danger)');
+ expect(theme.variables?.colorSuccess).toBe('var(--clerk-color-success)');
+ expect(theme.variables?.colorWarning).toBe('var(--clerk-color-warning)');
+ expect(theme.variables?.colorNeutral).toBe('var(--clerk-color-neutral)');
+ expect(theme.variables?.colorInput).toBe('var(--clerk-color-input)');
+ expect(theme.variables?.colorInputForeground).toBe('var(--clerk-color-input-foreground)');
+ expect(theme.variables?.colorShimmer).toBe('var(--clerk-color-shimmer)');
+ expect(theme.variables?.colorRing).toBe('var(--clerk-color-ring)');
+ expect(theme.variables?.colorShadow).toBe('var(--clerk-color-shadow)');
+ expect(theme.variables?.colorBorder).toBe('var(--clerk-color-border)');
+ expect(theme.variables?.colorModalBackdrop).toBe('var(--clerk-color-modal-backdrop)');
+
+ // Should generate comprehensive CSS
+ const css = (theme as any).__internal_globalCss;
+ expect(css).toContain('--clerk-color-background: #ffffff;');
+ expect(css).toContain('--clerk-color-background: #1a1a1a;');
+ expect(css).toContain('--clerk-color-danger: #dc3545;');
+ expect(css).toContain('--clerk-color-danger: #e74c3c;');
+ expect(css).toContain('--clerk-color-modal-backdrop: rgba(0,0,0,0.5);');
+ expect(css).toContain('--clerk-color-modal-backdrop: rgba(0,0,0,0.8);');
+ });
+ });
+});
diff --git a/packages/themes/src/createTheme.ts b/packages/themes/src/createTheme.ts
index 2c5e86f844e..b3ca7be1e4c 100644
--- a/packages/themes/src/createTheme.ts
+++ b/packages/themes/src/createTheme.ts
@@ -1,20 +1,317 @@
-// Temp way to import the type. We will clean this up when we extract
-// theming into its own package
-import type { Appearance, BaseTheme, DeepPartial, Elements, Theme } from '@clerk/types';
+import type { Appearance, BaseTheme, Elements, Variables } from '@clerk/types';
-import type { InternalTheme } from '../../clerk-js/src/ui/foundations';
+export type DarkModeElementsFunction = (darkModeSelector: string) => Elements;
-interface CreateClerkThemeParams extends DeepPartial {
- /**
- * {@link Theme.elements}
- */
- elements?: Elements | ((params: { theme: InternalTheme }) => Elements);
+// Support for tuple values in variables like ['#ffffff', '#212126'] (light, dark)
+export type VariableValue = string | readonly [light: string, dark: string];
+
+// Extract all color variable keys from Variables type
+type ColorVariableKeys = {
+ [K in keyof Variables]: K extends `color${string}` ? K : never;
+}[keyof Variables];
+
+// Manually exclude deprecated color variables (marked with @deprecated in Variables type)
+type DeprecatedColorVariables =
+ | 'colorTextOnPrimaryBackground' // @deprecated Use colorPrimaryForeground instead
+ | 'colorText' // @deprecated Use colorForeground instead
+ | 'colorTextSecondary' // @deprecated Use colorMutedForeground instead
+ | 'colorInputText' // @deprecated Use colorInputForeground instead
+ | 'colorInputBackground'; // @deprecated Use colorInput instead
+
+// Get all non-deprecated color variables that should support tuple values
+type NonDeprecatedColorVariables = Exclude;
+
+// Extended Variables type that supports tuple values for all non-deprecated color properties
+export type VariablesWithTuples = {
+ [K in keyof Variables]: K extends NonDeprecatedColorVariables ? string | [string, string] : Variables[K];
+};
+
+type ProcessedVariables = {
+ cssString: string | null;
+ processedVariables: Record;
+};
+
+type ThemeConfiguration = {
+ variables?: VariablesWithTuples;
+ elements?: Elements | DarkModeElementsFunction;
+ baseTheme?: BaseTheme;
+};
+
+type ThemeOptions = {
+ darkModeSelector?: string | false;
+};
+
+const CAMEL_CASE_REGEX = /[A-Z]/g;
+const LEADING_HYPHEN_REGEX = /^-/;
+const CSS_VAR_PREFIX = '--clerk-';
+const THEME_TYPE = 'prebuilt_appearance';
+const MEDIA_QUERY_PREFIX = '@media';
+const CLASS_SELECTOR_PREFIX = '.';
+const ID_SELECTOR_PREFIX = '#';
+const INTERNAL_GLOBAL_CSS_KEY = '__internal_globalCss';
+
+/**
+ * Converts camelCase to kebab-case for CSS variables
+ * @example toKebabCase('colorBackground') => 'color-background'
+ * @example toKebabCase('XMLParser') => 'x-m-l-parser' (no leading hyphen)
+ */
+export function toKebabCase(str: string): string {
+ return str.replace(CAMEL_CASE_REGEX, letter => `-${letter.toLowerCase()}`).replace(LEADING_HYPHEN_REGEX, '');
+}
+
+/**
+ * Normalizes darkModeSelector with support for opting out
+ * @param selector - The dark mode selector, false to opt out, or undefined for media query fallback
+ * @returns null if opted out, otherwise a valid selector string
+ */
+export function normalizeDarkModeSelector(selector?: string | false): string | null {
+ if (selector === false) {
+ return null; // Opt out of dark mode CSS generation
+ }
+
+ // If no selector provided, opt out by default (light-only theme)
+ // Dark mode should be explicit opt-in behavior
+ if (selector === undefined) {
+ return null;
+ }
+
+ // Use provided selector, trimmed
+ return selector.trim() || null;
+}
+
+/**
+ * Converts tuple variables to CSS variables and generates theme CSS
+ * Transforms tuples like ['#ffffff', '#000000'] into CSS custom properties
+ * @param variables - Variables object that may contain tuples
+ * @param darkModeSelector - Dark mode selector string, or null to opt out of CSS generation
+ */
+export function convertTuplesToCssVariables(
+ variables: Record,
+ darkModeSelector?: string | null,
+): ProcessedVariables {
+ const lightRules: string[] = [];
+ const darkRules: string[] = [];
+ const processedVariables: Record = {};
+
+ for (const [key, value] of Object.entries(variables)) {
+ if (Array.isArray(value) && value.length === 2) {
+ const [lightValue, darkValue] = value;
+
+ if (darkModeSelector === null) {
+ processedVariables[key] = lightValue;
+ } else {
+ const cssVarName = `${CSS_VAR_PREFIX}${toKebabCase(key)}`;
+
+ lightRules.push(` ${cssVarName}: ${lightValue};`);
+ darkRules.push(` ${cssVarName}: ${darkValue};`);
+
+ processedVariables[key] = `var(${cssVarName})`;
+ }
+ } else {
+ processedVariables[key] = value;
+ }
+ }
+
+ let cssString: string | null = null;
+ if (lightRules.length > 0 && darkModeSelector !== null) {
+ const normalizedSelector = darkModeSelector as string;
+
+ if (normalizedSelector.startsWith(MEDIA_QUERY_PREFIX)) {
+ // Media query - wrap dark rules in :root
+ cssString = `
+ :root {
+${lightRules.join('\n')}
+ }
+ ${normalizedSelector} {
+ :root {
+${darkRules.join('\n')}
+ }
+ }
+ `.trim();
+ } else {
+ // Regular selector - use as-is
+ cssString = `
+ :root {
+${lightRules.join('\n')}
+ }
+ ${normalizedSelector} {
+${darkRules.join('\n')}
+ }
+ `.trim();
+ }
+ }
+
+ return { cssString, processedVariables };
+}
+
+/**
+ * Transforms a CSS selector for proper nesting context
+ * Class selectors like '.dark' become '.dark &' for parent-child relationship
+ * ID selectors like '#theme' become '#theme &' for parent-child relationship
+ */
+export function transformSelectorForNesting(selector: string): string {
+ // If it's a class or ID selector, append & for proper CSS nesting
+ if (
+ (selector.startsWith(CLASS_SELECTOR_PREFIX) || selector.startsWith(ID_SELECTOR_PREFIX)) &&
+ !selector.includes('&')
+ ) {
+ return `${selector} &`;
+ }
+ // For media queries and other selectors, return as-is
+ return selector;
+}
+
+/**
+ * Resolves elements configuration - handles both static elements and function-based elements
+ */
+export function resolveElementsConfiguration(
+ elements: Elements | DarkModeElementsFunction | undefined,
+ darkModeSelector: string | null,
+): Elements | undefined {
+ if (!elements) {
+ return undefined;
+ }
+
+ // If it's a function, call it with darkModeSelector
+ if (typeof elements === 'function') {
+ // If opted out of dark mode, don't call the function at all
+ // since it's designed to generate dark mode styles
+ if (darkModeSelector === null) {
+ return undefined;
+ }
+
+ try {
+ // Use the explicit selector provided (no fallbacks)
+ const rawSelector = darkModeSelector;
+ const selector = transformSelectorForNesting(rawSelector);
+ return (elements as DarkModeElementsFunction)(selector);
+ } catch (error) {
+ console.warn('[Clerk Theme] Failed to call elements function with darkModeSelector:', error);
+ return undefined;
+ }
+ }
+
+ return elements;
+}
+
+/**
+ * Resolves a baseTheme, handling both static themes and factory functions
+ */
+export function resolveBaseTheme(baseTheme: BaseTheme | undefined, options?: ThemeOptions): BaseTheme | undefined {
+ if (!baseTheme) {
+ return undefined;
+ }
+
+ // If baseTheme is a factory function, call it with the same options
+ if (typeof baseTheme === 'function') {
+ try {
+ return (baseTheme as any)(options);
+ } catch (error) {
+ console.warn('[Clerk Theme] Failed to call baseTheme factory function:', error);
+ return baseTheme as BaseTheme; // Fallback to treating it as static theme
+ }
+ }
+
+ return baseTheme;
}
-export const experimental_createTheme = (appearance: Appearance): BaseTheme => {
- // Placeholder method that might hande more transformations in the future
- return {
- ...appearance,
- __type: 'prebuilt_appearance',
+/**
+ * Merges theme configurations, with child theme taking precedence
+ */
+export function mergeThemeConfigurations(
+ baseTheme: BaseTheme | undefined,
+ childConfig: ThemeConfiguration,
+): {
+ mergedVariables: Record | undefined;
+ mergedElements: Elements | undefined;
+ mergedConfig: Record;
+} {
+ const resolvedBase = (baseTheme as any) || {};
+
+ // Merge variables and elements, avoiding empty objects
+ const mergedVariables =
+ resolvedBase.variables || childConfig.variables
+ ? { ...resolvedBase.variables, ...childConfig.variables }
+ : undefined;
+
+ const mergedElements =
+ resolvedBase.elements || childConfig.elements ? { ...resolvedBase.elements, ...childConfig.elements } : undefined;
+
+ const mergedConfig = {
+ ...resolvedBase,
+ ...childConfig,
};
-};
+
+ return { mergedVariables, mergedElements, mergedConfig };
+}
+
+/**
+ * Creates the final theme object with proper typing and internal properties
+ */
+export function createThemeObject(
+ config: Record,
+ variables: Record | undefined,
+ elements: Elements | undefined,
+ globalCss: string | null,
+): BaseTheme {
+ const result = {
+ ...config,
+ __type: THEME_TYPE,
+ } as BaseTheme;
+
+ // Only set variables/elements if they have content
+ if (variables) (result as any).variables = variables;
+ if (elements) (result as any).elements = elements;
+
+ // Include global CSS string if it exists (for media query styles)
+ if (globalCss) (result as any)[INTERNAL_GLOBAL_CSS_KEY] = globalCss;
+
+ return result;
+}
+
+/**
+ * Creates a theme factory function that can generate themed instances
+ */
+export function createThemeFactory(config: ThemeConfiguration): (options?: ThemeOptions) => BaseTheme {
+ return (options?: ThemeOptions) => {
+ const darkModeSelector = normalizeDarkModeSelector(options?.darkModeSelector);
+
+ const resolvedBaseTheme = resolveBaseTheme(config.baseTheme, options);
+
+ const processedElements = resolveElementsConfiguration(config.elements, darkModeSelector);
+
+ const { mergedVariables, mergedElements, mergedConfig } = mergeThemeConfigurations(resolvedBaseTheme, {
+ ...config,
+ elements: processedElements,
+ });
+
+ let globalCssString: string | null = null;
+ let finalVariables = mergedVariables;
+
+ if (mergedVariables) {
+ const { cssString, processedVariables } = convertTuplesToCssVariables(mergedVariables, darkModeSelector);
+ globalCssString = cssString;
+ finalVariables = processedVariables;
+ }
+
+ return createThemeObject(mergedConfig, finalVariables, mergedElements, globalCssString);
+ };
+}
+
+// Overload for themes with function-based elements
+export function experimental_createTheme<
+ T extends { variables?: VariablesWithTuples; elements: DarkModeElementsFunction; baseTheme?: BaseTheme },
+>(appearance: Appearance): BaseTheme & ((options?: ThemeOptions) => BaseTheme);
+// Overload for themes with static elements or no elements
+export function experimental_createTheme(
+ appearance: Appearance,
+): BaseTheme & ((options?: ThemeOptions) => BaseTheme);
+export function experimental_createTheme(appearance: any) {
+ const themeFactory = createThemeFactory(appearance);
+
+ const defaultTheme = themeFactory();
+
+ const callableTheme = Object.assign(themeFactory, defaultTheme);
+
+ return callableTheme as BaseTheme & typeof themeFactory;
+}
diff --git a/packages/themes/src/themes/clerk.ts b/packages/themes/src/themes/clerk.ts
new file mode 100644
index 00000000000..c7fa234017f
--- /dev/null
+++ b/packages/themes/src/themes/clerk.ts
@@ -0,0 +1,29 @@
+import { experimental_createTheme } from '../createTheme';
+
+export const clerk = experimental_createTheme({
+ variables: {
+ colorBackground: ['#ffffff', '#212126'],
+ colorNeutral: ['#000000', '#ffffff'],
+ colorPrimary: ['#000000', '#ffffff'],
+ colorPrimaryForeground: ['#ffffff', '#000000'],
+ colorForeground: ['#000000', '#ffffff'],
+ colorInputForeground: ['#000000', '#ffffff'],
+ colorInput: ['#ffffff', '#26262B'],
+ colorModalBackdrop: ['#000000', '#000000'],
+ },
+ elements: darkModeSelector => {
+ return {
+ providerIcon__apple: { [darkModeSelector]: { filter: 'invert(1)' } },
+ providerIcon__github: { [darkModeSelector]: { filter: 'invert(1)' } },
+ providerIcon__okx_wallet: { [darkModeSelector]: { filter: 'invert(1)' } },
+ activeDeviceIcon: {
+ [darkModeSelector]: {
+ '--cl-chassis-bottom': '#d2d2d2',
+ '--cl-chassis-back': '#e6e6e6',
+ '--cl-chassis-screen': '#e6e6e6',
+ '--cl-screen': '#111111',
+ },
+ },
+ };
+ },
+});
diff --git a/packages/themes/src/themes/index.ts b/packages/themes/src/themes/index.ts
index b57a2cb704d..6faebecc1ce 100644
--- a/packages/themes/src/themes/index.ts
+++ b/packages/themes/src/themes/index.ts
@@ -1,3 +1,4 @@
+export * from './clerk';
export * from './dark';
export * from './shadesOfPurple';
export * from './neobrutalism';
diff --git a/packages/themes/src/themes/neobrutalism.ts b/packages/themes/src/themes/neobrutalism.ts
index d992eeb68d7..a60fa5e2965 100644
--- a/packages/themes/src/themes/neobrutalism.ts
+++ b/packages/themes/src/themes/neobrutalism.ts
@@ -20,7 +20,6 @@ const shadowStyle = {
};
export const neobrutalism = experimental_createTheme({
- //@ts-expect-error not public api
simpleStyles: true,
variables: {
colorPrimary: '#DF1B1B',
diff --git a/packages/themes/tsup.config.ts b/packages/themes/tsup.config.ts
index 4e91f53be8d..806b0ab3055 100644
--- a/packages/themes/tsup.config.ts
+++ b/packages/themes/tsup.config.ts
@@ -3,7 +3,7 @@ import { extname, join } from 'path';
import { defineConfig } from 'tsup';
export default defineConfig({
- entry: ['./src/**/*.{ts,tsx}'],
+ entry: ['./src/**/*.{ts,tsx}', '!./src/**/*.test.{ts,tsx}', '!./src/**/__tests__/**'],
format: ['cjs', 'esm'],
bundle: false,
clean: true,
diff --git a/packages/themes/vitest.config.ts b/packages/themes/vitest.config.ts
new file mode 100644
index 00000000000..014f97ef76d
--- /dev/null
+++ b/packages/themes/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ globals: true,
+ },
+});
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index e04f74b2598..f3ce69edaac 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -805,7 +805,10 @@ export type Variables = {
spacing?: CssLengthUnit;
};
-export type BaseThemeTaggedType = { __type: 'prebuilt_appearance' };
+export type BaseThemeTaggedType = {
+ __type: 'prebuilt_appearance';
+ __internal_globalCss?: string;
+};
export type BaseTheme = (BaseThemeTaggedType | 'clerk' | 'simple') & { cssLayerName?: string };
export type Theme = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9c5d289f1f8..4a942f70e68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ catalogs:
typescript:
specifier: 5.8.3
version: 5.8.3
+ vitest:
+ specifier: 3.0.5
+ version: 3.0.5
zx:
specifier: 8.7.1
version: 8.7.1
@@ -1022,6 +1025,9 @@ importers:
tsup:
specifier: catalog:repo
version: 8.5.0(@swc/core@1.11.29(@swc/helpers@0.5.17))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0)
+ vitest:
+ specifier: catalog:repo
+ version: 3.0.5(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@24.0.14)(jiti@2.4.2)(jsdom@24.1.3)(lightningcss@1.27.0)(msw@2.10.4(@types/node@24.0.14)(typescript@5.8.3))(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.0)
packages/types:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 1aeee6b4c12..8cd61004ad3 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -19,4 +19,5 @@ catalogs:
tslib: 2.8.1
tsup: 8.5.0
typescript: 5.8.3
+ vitest: 3.0.5
zx: 8.7.1