Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
12 changes: 11 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 @@ -2727,9 +2728,18 @@ 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);
}

console.log('processedOptions', processedOptions);

return {
...defaultOptions,
...options,
...processedOptions,
allowedRedirectOrigins: createAllowedRedirectOrigins(
options?.allowedRedirectOrigins,
this.frontendApi,
Expand Down
3 changes: 1 addition & 2 deletions packages/clerk-js/src/ui/elements/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,14 +279,13 @@ const NavButton = (props: NavButtonProps) => {
gap: t.space.$3,
justifyContent: 'flex-start',
backgroundColor: isActive ? t.colors.$neutralAlpha100 : undefined,
color: isActive ? t.colors.$primary500 : t.colors.$neutralAlpha600,
color: isActive ? t.colors.$primary500 : t.colors.$colorMutedForeground,
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a change I think we should make. We have a secondary text color that should be used over the neutralAlpha + opacity which currently doesn't have enough contrast in my opinion. This update looks much better in practice.

BEFORE AFTER
Screenshot 2025-07-16 at 2 50 40 PM Screenshot 2025-07-16 at 2 50 04 PM

'&:hover': {
backgroundColor: isActive ? undefined : t.colors.$neutralAlpha25,
},
'&:focus': {
backgroundColor: isActive ? undefined : t.colors.$neutralAlpha50,
},
opacity: isActive ? 1 : 0.6,
}),
sx,
]}
Expand Down
173 changes: 173 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,173 @@
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();
}
});
});
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';
32 changes: 32 additions & 0 deletions packages/themes/src/themes/shadcn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { experimental_createTheme } from '../createTheme';

export const shadcn = experimental_createTheme({
cssLayerName: 'components',
variables: {
colorBackground: 'var(--card)',
colorDanger: 'var(--destructive)',
colorForeground: 'var(--card-foreground)',
colorInput: 'var(--input)',
colorInputForeground: 'var(--card-foreground)',
colorModalBackdrop: 'var(--color-black)',
colorMuted: 'var(--muted)',
colorMutedForeground: 'var(--muted-foreground)',
colorNeutral: 'var(--foreground)',
colorPrimary: 'var(--primary)',
colorPrimaryForeground: 'var(--primary-foreground)',
colorRing: 'var(--ring)',
fontWeight: {
bold: 'var(--font-weight-semibold)',
},
},
elements: {
input: 'bg-transparent dark:bg-input/30',
cardBox: 'shadow-sm border',
popoverBox: 'shadow-sm border',
button: {
'&::after': {
display: 'none',
},
},
},
});
2 changes: 1 addition & 1 deletion packages/types/src/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

export type CssColorOrScale = string | ColorScaleWithRequiredBase;
export type CssColorOrAlphaScale = string | AlphaColorScale;
type CssColor = string | TransparentColor | BuiltInColors;

Check warning on line 54 in packages/types/src/appearance.ts

View workflow job for this annotation

GitHub Actions / Static analysis

"black" | "blue" | "red" | "green" | "grey" | "white" | "yellow" is overridden by string in this union type

Check warning on line 54 in packages/types/src/appearance.ts

View workflow job for this annotation

GitHub Actions / Static analysis

"transparent" is overridden by string in this union type
type CssLengthUnit = string;

type FontSizeScale = {
Expand Down Expand Up @@ -84,7 +84,7 @@
| 'Trebuchet MS'
| 'Verdana';

export type FontFamily = string | WebSafeFont;

Check warning on line 87 in packages/types/src/appearance.ts

View workflow job for this annotation

GitHub Actions / Static analysis

"Arial" | "Brush Script MT" | "Courier New" | "Garamond" | "Georgia" | "Helvetica" | "Tahoma" | "Times New Roman" | "Trebuchet MS" | "Verdana" is overridden by string in this union type

type LoadingState = 'loading';
type ErrorState = 'error';
Expand Down Expand Up @@ -806,7 +806,7 @@
};

export type BaseThemeTaggedType = { __type: 'prebuilt_appearance' };
export type BaseTheme = BaseThemeTaggedType;
export type BaseTheme = BaseThemeTaggedType & { cssLayerName?: string };

export type Theme = {
/**
Expand Down
Loading