- 
                Notifications
    
You must be signed in to change notification settings  - Fork 402
 
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 19 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
    
  
  
    
              
        
          
          
            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', () => { | ||
| 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.
👍 Awesome that you pulled this out and made it testable.