Skip to content

Commit a04a8f5

Browse files
feat(themes): Add shadcn theme (#6322)
1 parent e5ba060 commit a04a8f5

File tree

10 files changed

+391
-3
lines changed

10 files changed

+391
-3
lines changed

.changeset/heavy-keys-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/themes': minor
3+
---
4+
5+
Add shadcn theme to @clerk/themes

.changeset/ready-hats-vanish.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/themes': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Add optional `cssLayerName` to `BaseTheme` object

packages/clerk-js/src/core/clerk.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import {
112112
isRedirectForFAPIInitiatedFlow,
113113
noOrganizationExists,
114114
noUserExists,
115+
processCssLayerNameExtraction,
115116
removeClerkQueryParam,
116117
requiresUserInput,
117118
sessionExistsAndSingleSessionModeEnabled,
@@ -2731,9 +2732,16 @@ export class Clerk implements ClerkInterface {
27312732
};
27322733

27332734
#initOptions = (options?: ClerkOptions): ClerkOptions => {
2735+
const processedOptions = options ? { ...options } : {};
2736+
2737+
// Extract cssLayerName from baseTheme if present and move it to appearance level
2738+
if (processedOptions.appearance) {
2739+
processedOptions.appearance = processCssLayerNameExtraction(processedOptions.appearance);
2740+
}
2741+
27342742
return {
27352743
...defaultOptions,
2736-
...options,
2744+
...processedOptions,
27372745
allowedRedirectOrigins: createAllowedRedirectOrigins(
27382746
options?.allowedRedirectOrigins,
27392747
this.frontendApi,
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import type { Appearance, BaseTheme } from '@clerk/types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { processCssLayerNameExtraction } from '../appearance';
5+
6+
describe('processCssLayerNameExtraction', () => {
7+
it('extracts cssLayerName from single baseTheme and moves it to appearance level', () => {
8+
const appearance: Appearance = {
9+
baseTheme: {
10+
__type: 'prebuilt_appearance' as const,
11+
cssLayerName: 'theme-layer',
12+
},
13+
};
14+
15+
const result = processCssLayerNameExtraction(appearance);
16+
17+
expect(result?.cssLayerName).toBe('theme-layer');
18+
expect(result?.baseTheme).toBeDefined();
19+
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
20+
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
21+
expect(result.baseTheme.__type).toBe('prebuilt_appearance');
22+
}
23+
});
24+
25+
it('preserves appearance-level cssLayerName over baseTheme cssLayerName', () => {
26+
const appearance: Appearance = {
27+
cssLayerName: 'appearance-layer',
28+
baseTheme: {
29+
__type: 'prebuilt_appearance' as const,
30+
cssLayerName: 'theme-layer',
31+
},
32+
};
33+
34+
const result = processCssLayerNameExtraction(appearance);
35+
36+
expect(result?.cssLayerName).toBe('appearance-layer');
37+
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
38+
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
39+
}
40+
});
41+
42+
it('extracts cssLayerName from first theme in array that has one', () => {
43+
const appearance: Appearance = {
44+
baseTheme: [
45+
{
46+
__type: 'prebuilt_appearance' as const,
47+
},
48+
{
49+
__type: 'prebuilt_appearance' as const,
50+
cssLayerName: 'first-layer',
51+
},
52+
{
53+
__type: 'prebuilt_appearance' as const,
54+
cssLayerName: 'second-layer',
55+
},
56+
],
57+
};
58+
59+
const result = processCssLayerNameExtraction(appearance);
60+
61+
expect(result?.cssLayerName).toBe('first-layer');
62+
expect(result?.baseTheme).toBeDefined();
63+
if (result?.baseTheme && Array.isArray(result.baseTheme)) {
64+
expect(result.baseTheme).toHaveLength(3);
65+
expect((result.baseTheme[0] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
66+
expect((result.baseTheme[1] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
67+
expect((result.baseTheme[2] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
68+
result.baseTheme.forEach(theme => {
69+
expect(theme.__type).toBe('prebuilt_appearance');
70+
});
71+
}
72+
});
73+
74+
it('preserves appearance-level cssLayerName over array baseTheme cssLayerName', () => {
75+
const appearance: Appearance = {
76+
cssLayerName: 'appearance-layer',
77+
baseTheme: [
78+
{
79+
__type: 'prebuilt_appearance' as const,
80+
cssLayerName: 'theme1-layer',
81+
},
82+
{
83+
__type: 'prebuilt_appearance' as const,
84+
cssLayerName: 'theme2-layer',
85+
},
86+
],
87+
};
88+
89+
const result = processCssLayerNameExtraction(appearance);
90+
91+
expect(result?.cssLayerName).toBe('appearance-layer');
92+
if (result?.baseTheme && Array.isArray(result.baseTheme)) {
93+
result.baseTheme.forEach(theme => {
94+
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
95+
});
96+
}
97+
});
98+
99+
it('handles single baseTheme without cssLayerName', () => {
100+
const appearance: Appearance = {
101+
baseTheme: {
102+
__type: 'prebuilt_appearance' as const,
103+
},
104+
};
105+
106+
const result = processCssLayerNameExtraction(appearance);
107+
108+
expect(result?.cssLayerName).toBeUndefined();
109+
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
110+
expect(result.baseTheme.__type).toBe('prebuilt_appearance');
111+
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
112+
}
113+
});
114+
115+
it('handles array of baseThemes without any cssLayerName', () => {
116+
const appearance: Appearance = {
117+
baseTheme: [
118+
{
119+
__type: 'prebuilt_appearance' as const,
120+
},
121+
{
122+
__type: 'prebuilt_appearance' as const,
123+
},
124+
],
125+
};
126+
127+
const result = processCssLayerNameExtraction(appearance);
128+
129+
expect(result?.cssLayerName).toBeUndefined();
130+
if (result?.baseTheme && Array.isArray(result.baseTheme)) {
131+
expect(result.baseTheme).toHaveLength(2);
132+
result.baseTheme.forEach(theme => {
133+
expect(theme.__type).toBe('prebuilt_appearance');
134+
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
135+
});
136+
}
137+
});
138+
139+
it('handles no baseTheme provided', () => {
140+
const appearance: Appearance = {
141+
cssLayerName: 'standalone-layer',
142+
};
143+
144+
const result = processCssLayerNameExtraction(appearance);
145+
146+
expect(result?.cssLayerName).toBe('standalone-layer');
147+
expect(result?.baseTheme).toBeUndefined();
148+
});
149+
150+
it('handles undefined appearance', () => {
151+
const result = processCssLayerNameExtraction(undefined);
152+
153+
expect(result).toBeUndefined();
154+
});
155+
156+
it('preserves other appearance properties', () => {
157+
const appearance: Appearance = {
158+
variables: { colorPrimary: 'blue' },
159+
baseTheme: {
160+
__type: 'prebuilt_appearance' as const,
161+
cssLayerName: 'theme-layer',
162+
},
163+
};
164+
165+
const result = processCssLayerNameExtraction(appearance);
166+
167+
expect(result?.cssLayerName).toBe('theme-layer');
168+
expect(result?.variables?.colorPrimary).toBe('blue');
169+
if (result?.baseTheme && !Array.isArray(result.baseTheme)) {
170+
expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
171+
}
172+
});
173+
174+
it('handles empty baseTheme array', () => {
175+
const appearance: Appearance = {
176+
baseTheme: [],
177+
};
178+
179+
const result = processCssLayerNameExtraction(appearance);
180+
181+
expect(result?.cssLayerName).toBeUndefined();
182+
expect(result?.baseTheme).toEqual([]);
183+
expect(Array.isArray(result?.baseTheme)).toBe(true);
184+
});
185+
186+
it('uses first valid cssLayerName from mixed array when appearance.cssLayerName is absent', () => {
187+
const appearance: Appearance = {
188+
baseTheme: [
189+
{
190+
__type: 'prebuilt_appearance' as const,
191+
// No cssLayerName in first theme
192+
},
193+
{
194+
__type: 'prebuilt_appearance' as const,
195+
cssLayerName: 'second-theme-layer',
196+
},
197+
{
198+
__type: 'prebuilt_appearance' as const,
199+
cssLayerName: 'third-theme-layer',
200+
},
201+
],
202+
};
203+
204+
const result = processCssLayerNameExtraction(appearance);
205+
206+
expect(result?.cssLayerName).toBe('second-theme-layer');
207+
expect(Array.isArray(result?.baseTheme)).toBe(true);
208+
if (Array.isArray(result?.baseTheme)) {
209+
expect(result.baseTheme).toHaveLength(3);
210+
// Check that cssLayerName was removed from all themes
211+
result.baseTheme.forEach(theme => {
212+
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
213+
});
214+
}
215+
});
216+
217+
it('preserves appearance.cssLayerName over baseTheme array cssLayerName', () => {
218+
const appearance: Appearance = {
219+
cssLayerName: 'appearance-level-layer',
220+
baseTheme: [
221+
{
222+
__type: 'prebuilt_appearance' as const,
223+
cssLayerName: 'theme-layer-1',
224+
},
225+
{
226+
__type: 'prebuilt_appearance' as const,
227+
cssLayerName: 'theme-layer-2',
228+
},
229+
],
230+
};
231+
232+
const result = processCssLayerNameExtraction(appearance);
233+
234+
expect(result?.cssLayerName).toBe('appearance-level-layer');
235+
expect(Array.isArray(result?.baseTheme)).toBe(true);
236+
if (Array.isArray(result?.baseTheme)) {
237+
expect(result.baseTheme).toHaveLength(2);
238+
// Check that cssLayerName was removed from all themes
239+
result.baseTheme.forEach(theme => {
240+
expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined();
241+
});
242+
}
243+
});
244+
245+
it('returns single theme unchanged when it has no cssLayerName', () => {
246+
const appearance: Appearance = {
247+
baseTheme: {
248+
__type: 'prebuilt_appearance' as const,
249+
// No cssLayerName property
250+
},
251+
};
252+
253+
const result = processCssLayerNameExtraction(appearance);
254+
255+
expect(result?.cssLayerName).toBeUndefined();
256+
expect(result?.baseTheme).toEqual({
257+
__type: 'prebuilt_appearance',
258+
});
259+
expect(Array.isArray(result?.baseTheme)).toBe(false);
260+
});
261+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Appearance, BaseTheme } from '@clerk/types';
2+
3+
/**
4+
* Extracts cssLayerName from baseTheme and moves it to appearance level.
5+
* This is a pure function that can be tested independently.
6+
*/
7+
export function processCssLayerNameExtraction(appearance: Appearance | undefined): Appearance | undefined {
8+
if (!appearance || typeof appearance !== 'object' || !('baseTheme' in appearance) || !appearance.baseTheme) {
9+
return appearance;
10+
}
11+
12+
let cssLayerNameFromBaseTheme: string | undefined;
13+
14+
if (Array.isArray(appearance.baseTheme)) {
15+
// Handle array of themes - extract cssLayerName from each and use the first one found
16+
appearance.baseTheme.forEach((theme: BaseTheme) => {
17+
if (!cssLayerNameFromBaseTheme && theme.cssLayerName) {
18+
cssLayerNameFromBaseTheme = theme.cssLayerName;
19+
}
20+
});
21+
22+
// Create array without cssLayerName properties
23+
const processedBaseThemeArray = appearance.baseTheme.map((theme: BaseTheme) => {
24+
const { cssLayerName, ...rest } = theme;
25+
return rest;
26+
});
27+
28+
// Use existing cssLayerName at appearance level, or fall back to one from baseTheme(s)
29+
const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromBaseTheme;
30+
31+
const result = {
32+
...appearance,
33+
baseTheme: processedBaseThemeArray,
34+
};
35+
36+
if (finalCssLayerName) {
37+
result.cssLayerName = finalCssLayerName;
38+
}
39+
40+
return result;
41+
} else {
42+
// Handle single theme
43+
const singleTheme = appearance.baseTheme;
44+
let cssLayerNameFromSingleTheme: string | undefined;
45+
46+
if (singleTheme.cssLayerName) {
47+
cssLayerNameFromSingleTheme = singleTheme.cssLayerName;
48+
}
49+
50+
// Create new theme without cssLayerName
51+
const { cssLayerName, ...processedBaseTheme } = singleTheme;
52+
53+
// Use existing cssLayerName at appearance level, or fall back to one from baseTheme
54+
const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromSingleTheme;
55+
56+
const result = {
57+
...appearance,
58+
baseTheme: processedBaseTheme,
59+
};
60+
61+
if (finalCssLayerName) {
62+
result.cssLayerName = finalCssLayerName;
63+
}
64+
65+
return result;
66+
}
67+
}

packages/clerk-js/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './beforeUnloadTracker';
2+
export * from './appearance';
23
export * from './commerce';
34
export * from './completeSignUpFlow';
45
export * from './componentGuards';

packages/themes/src/createTheme.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,8 @@ interface CreateClerkThemeParams extends DeepPartial<Theme> {
1313

1414
export const experimental_createTheme = (appearance: Appearance<CreateClerkThemeParams>): BaseTheme => {
1515
// Placeholder method that might hande more transformations in the future
16-
return { ...appearance, __type: 'prebuilt_appearance' };
16+
return {
17+
...appearance,
18+
__type: 'prebuilt_appearance',
19+
};
1720
};

packages/themes/src/themes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './dark';
22
export * from './shadesOfPurple';
33
export * from './neobrutalism';
4+
export * from './shadcn';
45
export * from './simple';

0 commit comments

Comments
 (0)