Skip to content

feat(themes): Add shadcn theme #6322

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3a789b4
feat(themes): Add shadcn theme
alexcarpenter Jul 15, 2025
a754749
draft changeset
alexcarpenter Jul 15, 2025
ffdbaf6
add variables
alexcarpenter Jul 16, 2025
f372013
remove borderRadius
alexcarpenter Jul 16, 2025
c4d3803
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter Jul 16, 2025
bedb070
use colorMutedForeground for nav links
alexcarpenter Jul 16, 2025
6080472
update vars
alexcarpenter Jul 16, 2025
9fbc4e7
updates
alexcarpenter Jul 16, 2025
365d489
rename cssLayerName to components
alexcarpenter Jul 16, 2025
e511713
hide button overlay
alexcarpenter Jul 16, 2025
46cba11
Update shadcn.ts
alexcarpenter Jul 17, 2025
781b867
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter Jul 17, 2025
ed4f6e6
remove CSS layer name
alexcarpenter Jul 17, 2025
f6cc7d0
feat(clerk-js): Add `cssLayerName` option to `experimental_createThem…
alexcarpenter Jul 18, 2025
80671d6
remove log
alexcarpenter Jul 18, 2025
de43a5d
add more test cases
alexcarpenter Jul 18, 2025
6b43103
Revert "remove log"
alexcarpenter Jul 18, 2025
21e4e6d
remove log
alexcarpenter Jul 18, 2025
72eea01
revert NavButton changes
alexcarpenter Jul 18, 2025
3071fb6
add changeset
alexcarpenter Jul 21, 2025
dc32dbd
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter Jul 21, 2025
6b94eef
Add font weight variables and refine button selector
alexcarpenter Jul 21, 2025
e7275b5
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter Jul 22, 2025
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
5 changes: 5 additions & 0 deletions .changeset/heavy-keys-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/themes': minor
---

Add shadcn theme to @clerk/themes
7 changes: 7 additions & 0 deletions .changeset/ready-hats-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/themes': minor
Copy link
Member Author

Choose a reason for hiding this comment

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

Is this the right approach for changeset, one for the newly added theme, and one for the new functionality to support cssLayerName, both marked as minor?

Copy link
Member

Choose a reason for hiding this comment

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

👍 that works fine!

'@clerk/types': minor
---

Add optional `cssLayerName` to `BaseTheme` object
10 changes: 9 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
isRedirectForFAPIInitiatedFlow,
noOrganizationExists,
noUserExists,
processCssLayerNameExtraction,
removeClerkQueryParam,
requiresUserInput,
sessionExistsAndSingleSessionModeEnabled,
Expand Down Expand Up @@ -2731,9 +2732,16 @@ export class Clerk implements ClerkInterface {
};

#initOptions = (options?: ClerkOptions): ClerkOptions => {
const processedOptions = options ? { ...options } : {};

// Extract cssLayerName from baseTheme if present and move it to appearance level
if (processedOptions.appearance) {
processedOptions.appearance = processCssLayerNameExtraction(processedOptions.appearance);
}

return {
...defaultOptions,
...options,
...processedOptions,
allowedRedirectOrigins: createAllowedRedirectOrigins(
options?.allowedRedirectOrigins,
this.frontendApi,
Expand Down
261 changes: 261 additions & 0 deletions packages/clerk-js/src/utils/__tests__/appearance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import type { Appearance, BaseTheme } from '@clerk/types';
import { describe, expect, it } from 'vitest';

import { processCssLayerNameExtraction } from '../appearance';

describe('processCssLayerNameExtraction', () => {
Copy link
Member

Choose a reason for hiding this comment

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

👍 Awesome that you pulled this out and made it testable.

it('extracts cssLayerName from single baseTheme and moves it to appearance level', () => {
const appearance: Appearance = {
baseTheme: {
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme-layer',
},
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('theme-layer');
expect(result?.baseTheme).toBeDefined();
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
expect(result.baseTheme.__type).toBe('prebuilt_appearance');
}
});

it('preserves appearance-level cssLayerName over baseTheme cssLayerName', () => {
const appearance: Appearance = {
cssLayerName: 'appearance-layer',
baseTheme: {
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme-layer',
},
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('appearance-layer');
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
}
});

it('extracts cssLayerName from first theme in array that has one', () => {
const appearance: Appearance = {
baseTheme: [
{
__type: 'prebuilt_appearance' as const,
},
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'first-layer',
},
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'second-layer',
},
],
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('first-layer');
expect(result?.baseTheme).toBeDefined();
if (result?.baseTheme && Array.isArray(result.baseTheme)) {
expect(result.baseTheme).toHaveLength(3);
expect((result.baseTheme[0] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
expect((result.baseTheme[1] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
expect((result.baseTheme[2] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
result.baseTheme.forEach(theme => {
expect(theme.__type).toBe('prebuilt_appearance');
});
}
});

it('preserves appearance-level cssLayerName over array baseTheme cssLayerName', () => {
const appearance: Appearance = {
cssLayerName: 'appearance-layer',
baseTheme: [
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme1-layer',
},
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme2-layer',
},
],
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('appearance-layer');
if (result?.baseTheme && Array.isArray(result.baseTheme)) {
result.baseTheme.forEach(theme => {
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
});
}
});

it('handles single baseTheme without cssLayerName', () => {
const appearance: Appearance = {
baseTheme: {
__type: 'prebuilt_appearance' as const,
},
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBeUndefined();
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
expect(result.baseTheme.__type).toBe('prebuilt_appearance');
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
}
});

it('handles array of baseThemes without any cssLayerName', () => {
const appearance: Appearance = {
baseTheme: [
{
__type: 'prebuilt_appearance' as const,
},
{
__type: 'prebuilt_appearance' as const,
},
],
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBeUndefined();
if (result?.baseTheme && Array.isArray(result.baseTheme)) {
expect(result.baseTheme).toHaveLength(2);
result.baseTheme.forEach(theme => {
expect(theme.__type).toBe('prebuilt_appearance');
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
});
}
});

it('handles no baseTheme provided', () => {
const appearance: Appearance = {
cssLayerName: 'standalone-layer',
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('standalone-layer');
expect(result?.baseTheme).toBeUndefined();
});

it('handles undefined appearance', () => {
const result = processCssLayerNameExtraction(undefined);

expect(result).toBeUndefined();
});

it('preserves other appearance properties', () => {
const appearance: Appearance = {
variables: { colorPrimary: 'blue' },
baseTheme: {
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme-layer',
},
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('theme-layer');
expect(result?.variables?.colorPrimary).toBe('blue');
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
}
});

it('handles empty baseTheme array', () => {
const appearance: Appearance = {
baseTheme: [],
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBeUndefined();
expect(result?.baseTheme).toEqual([]);
expect(Array.isArray(result?.baseTheme)).toBe(true);
});

it('uses first valid cssLayerName from mixed array when appearance.cssLayerName is absent', () => {
const appearance: Appearance = {
baseTheme: [
{
__type: 'prebuilt_appearance' as const,
// No cssLayerName in first theme
},
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'second-theme-layer',
},
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'third-theme-layer',
},
],
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('second-theme-layer');
expect(Array.isArray(result?.baseTheme)).toBe(true);
if (Array.isArray(result?.baseTheme)) {
expect(result.baseTheme).toHaveLength(3);
// Check that cssLayerName was removed from all themes
result.baseTheme.forEach(theme => {
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
});
}
});

it('preserves appearance.cssLayerName over baseTheme array cssLayerName', () => {
const appearance: Appearance = {
cssLayerName: 'appearance-level-layer',
baseTheme: [
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme-layer-1',
},
{
__type: 'prebuilt_appearance' as const,
cssLayerName: 'theme-layer-2',
},
],
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBe('appearance-level-layer');
expect(Array.isArray(result?.baseTheme)).toBe(true);
if (Array.isArray(result?.baseTheme)) {
expect(result.baseTheme).toHaveLength(2);
// Check that cssLayerName was removed from all themes
result.baseTheme.forEach(theme => {
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
});
}
});

it('returns single theme unchanged when it has no cssLayerName', () => {
const appearance: Appearance = {
baseTheme: {
__type: 'prebuilt_appearance' as const,
// No cssLayerName property
},
};

const result = processCssLayerNameExtraction(appearance);

expect(result?.cssLayerName).toBeUndefined();
expect(result?.baseTheme).toEqual({
__type: 'prebuilt_appearance',
});
expect(Array.isArray(result?.baseTheme)).toBe(false);
});
});
67 changes: 67 additions & 0 deletions packages/clerk-js/src/utils/appearance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Appearance, BaseTheme } from '@clerk/types';

/**
* Extracts cssLayerName from baseTheme and moves it to appearance level.
* This is a pure function that can be tested independently.
*/
export function processCssLayerNameExtraction(appearance: Appearance | undefined): Appearance | undefined {
if (!appearance || typeof appearance !== 'object' || !('baseTheme' in appearance) || !appearance.baseTheme) {
return appearance;
}

let cssLayerNameFromBaseTheme: string | undefined;

if (Array.isArray(appearance.baseTheme)) {
// Handle array of themes - extract cssLayerName from each and use the first one found
appearance.baseTheme.forEach((theme: BaseTheme) => {
if (!cssLayerNameFromBaseTheme && theme.cssLayerName) {
cssLayerNameFromBaseTheme = theme.cssLayerName;
}
});

// Create array without cssLayerName properties
const processedBaseThemeArray = appearance.baseTheme.map((theme: BaseTheme) => {
const { cssLayerName, ...rest } = theme;
return rest;
});

// Use existing cssLayerName at appearance level, or fall back to one from baseTheme(s)
const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromBaseTheme;

const result = {
...appearance,
baseTheme: processedBaseThemeArray,
};

if (finalCssLayerName) {
result.cssLayerName = finalCssLayerName;
}

return result;
} else {
// Handle single theme
const singleTheme = appearance.baseTheme;
let cssLayerNameFromSingleTheme: string | undefined;

if (singleTheme.cssLayerName) {
cssLayerNameFromSingleTheme = singleTheme.cssLayerName;
}

// Create new theme without cssLayerName
const { cssLayerName, ...processedBaseTheme } = singleTheme;

// Use existing cssLayerName at appearance level, or fall back to one from baseTheme
const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromSingleTheme;

const result = {
...appearance,
baseTheme: processedBaseTheme,
};

if (finalCssLayerName) {
result.cssLayerName = finalCssLayerName;
}

return result;
}
}
1 change: 1 addition & 0 deletions packages/clerk-js/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './beforeUnloadTracker';
export * from './appearance';
export * from './commerce';
export * from './completeSignUpFlow';
export * from './componentGuards';
Expand Down
5 changes: 4 additions & 1 deletion packages/themes/src/createTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@ interface CreateClerkThemeParams extends DeepPartial<Theme> {

export const experimental_createTheme = (appearance: Appearance<CreateClerkThemeParams>): BaseTheme => {
// Placeholder method that might hande more transformations in the future
return { ...appearance, __type: 'prebuilt_appearance' };
return {
...appearance,
__type: 'prebuilt_appearance',
};
};
1 change: 1 addition & 0 deletions packages/themes/src/themes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './dark';
export * from './shadesOfPurple';
export * from './neobrutalism';
export * from './shadcn';
export * from './simple';
Loading
Loading