-
Notifications
You must be signed in to change notification settings - Fork 369
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
alexcarpenter
merged 23 commits into
main
from
alexcarpenter/user-2373-expose-shadcn-theme-from-clerkthemes
Jul 22, 2025
Merged
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 a754749
draft changeset
alexcarpenter ffdbaf6
add variables
alexcarpenter f372013
remove borderRadius
alexcarpenter c4d3803
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter bedb070
use colorMutedForeground for nav links
alexcarpenter 6080472
update vars
alexcarpenter 9fbc4e7
updates
alexcarpenter 365d489
rename cssLayerName to components
alexcarpenter e511713
hide button overlay
alexcarpenter 46cba11
Update shadcn.ts
alexcarpenter 781b867
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter ed4f6e6
remove CSS layer name
alexcarpenter f6cc7d0
feat(clerk-js): Add `cssLayerName` option to `experimental_createThem…
alexcarpenter 80671d6
remove log
alexcarpenter de43a5d
add more test cases
alexcarpenter 6b43103
Revert "remove log"
alexcarpenter 21e4e6d
remove log
alexcarpenter 72eea01
revert NavButton changes
alexcarpenter 3071fb6
add changeset
alexcarpenter dc32dbd
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter 6b94eef
Add font weight variables and refine button selector
alexcarpenter e7275b5
Merge branch 'main' into alexcarpenter/user-2373-expose-shadcn-theme-…
alexcarpenter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@clerk/themes': minor | ||
--- | ||
|
||
Add shadcn theme to @clerk/themes |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
--- | ||
'@clerk/clerk-js': minor | ||
'@clerk/themes': minor | ||
'@clerk/types': minor | ||
--- | ||
|
||
Add optional `cssLayerName` to `BaseTheme` object |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
261 changes: 261 additions & 0 deletions
261
packages/clerk-js/src/utils/__tests__/appearance.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
alexcarpenter marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 asminor
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 that works fine!