-
Notifications
You must be signed in to change notification settings - Fork 445
feat(expo): add native component theming via Expo config plugin #8243
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
base: main
Are you sure you want to change the base?
Changes from all commits
3f2f058
221e30c
b5af733
19bc65a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| --- | ||
| '@clerk/expo': minor | ||
| --- | ||
|
|
||
| Add native component theming via the Expo config plugin. You can now customize the appearance of Clerk's native components (`<AuthView />`, `<UserButton />`, `<UserProfileView />`) on iOS and Android by passing a `theme` prop to the plugin pointing at a JSON file: | ||
|
|
||
| ```json | ||
| { | ||
| "expo": { | ||
| "plugins": [ | ||
| ["@clerk/expo", { "theme": "./clerk-theme.json" }] | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The JSON theme supports: | ||
|
|
||
| - `colors` — 15 semantic color tokens (`primary`, `background`, `input`, `danger`, `success`, `warning`, `foreground`, `mutedForeground`, `primaryForeground`, `inputForeground`, `neutral`, `border`, `ring`, `muted`, `shadow`) as 6- or 8-digit hex strings. | ||
| - `darkColors` — same shape as `colors`; applied automatically when the system is in dark mode. | ||
| - `design.borderRadius` — number, applied to both platforms. | ||
| - `design.fontFamily` — string, **iOS only**. | ||
|
|
||
| Theme JSON is validated at prebuild. On iOS the theme is embedded into `Info.plist` (and `UIUserInterfaceStyle` is removed when `darkColors` is present, so the system can switch modes). On Android the JSON is copied into `android/app/src/main/assets/clerk_theme.json`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -585,6 +585,120 @@ const withClerkAppleSignIn = config => { | |
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Apply a custom theme to Clerk native components (iOS + Android). | ||
| * | ||
| * Accepts a `theme` prop pointing to a JSON file with optional keys: | ||
| * - colors: { primary, background, input, danger, success, warning, | ||
| * foreground, mutedForeground, primaryForeground, inputForeground, | ||
| * neutral, border, ring, muted, shadow } (hex color strings) | ||
| * - darkColors: same keys as colors (for dark mode) | ||
| * - design: { fontFamily: string, borderRadius: number } | ||
| * | ||
| * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme". | ||
| * When darkColors is present, removes UIUserInterfaceStyle to allow | ||
| * system dark mode. | ||
| * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json. | ||
| */ | ||
| const VALID_COLOR_KEYS = [ | ||
| 'primary', | ||
| 'background', | ||
| 'input', | ||
| 'danger', | ||
| 'success', | ||
| 'warning', | ||
| 'foreground', | ||
| 'mutedForeground', | ||
| 'primaryForeground', | ||
| 'inputForeground', | ||
| 'neutral', | ||
| 'border', | ||
| 'ring', | ||
| 'muted', | ||
| 'shadow', | ||
| ]; | ||
|
|
||
| const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; | ||
|
|
||
| function validateThemeJson(theme) { | ||
| const validateColors = (colors, label) => { | ||
| if (!colors || typeof colors !== 'object') return; | ||
| for (const [key, value] of Object.entries(colors)) { | ||
| if (!VALID_COLOR_KEYS.includes(key)) { | ||
| console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); | ||
| continue; | ||
| } | ||
| if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { | ||
| throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| if (theme.colors) validateColors(theme.colors, 'colors'); | ||
| if (theme.darkColors) validateColors(theme.darkColors, 'darkColors'); | ||
|
|
||
| if (theme.design) { | ||
| if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { | ||
| throw new Error(`Clerk theme: design.fontFamily must be a string`); | ||
| } | ||
| if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { | ||
| throw new Error(`Clerk theme: design.borderRadius must be a number`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const withClerkTheme = (config, props = {}) => { | ||
| const { theme } = props; | ||
| if (!theme) return config; | ||
|
|
||
| // Resolve the theme file path relative to the project root | ||
| const themePath = path.resolve(theme); | ||
| if (!fs.existsSync(themePath)) { | ||
| console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`); | ||
| return config; | ||
| } | ||
|
|
||
| let themeJson; | ||
| try { | ||
| themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8')); | ||
| validateThemeJson(themeJson); | ||
| } catch (e) { | ||
| throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`); | ||
| } | ||
|
|
||
| // iOS: Embed theme in Info.plist under "ClerkTheme" | ||
| config = withInfoPlist(config, modConfig => { | ||
| modConfig.modResults.ClerkTheme = themeJson; | ||
| console.log('✅ Embedded Clerk theme in Info.plist'); | ||
|
|
||
| // When darkColors is provided, remove UIUserInterfaceStyle to allow | ||
| // the system to switch between light and dark mode automatically. | ||
| if (themeJson.darkColors) { | ||
| delete modConfig.modResults.UIUserInterfaceStyle; | ||
| console.log('✅ Removed UIUserInterfaceStyle to enable system dark mode'); | ||
| } | ||
|
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. Something codex's local review spotted on this:
Member
Author
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. Oh hmmmm I didnt consider this when doing the dark mode / light mode switch. I will look into solutions. Good callout. |
||
|
|
||
| return modConfig; | ||
| }); | ||
|
|
||
| // Android: Copy theme JSON to assets | ||
| config = withDangerousMod(config, [ | ||
| 'android', | ||
| async config => { | ||
| const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets'); | ||
| if (!fs.existsSync(assetsDir)) { | ||
| fs.mkdirSync(assetsDir, { recursive: true }); | ||
| } | ||
| const destPath = path.join(assetsDir, 'clerk_theme.json'); | ||
| fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n'); | ||
| console.log('✅ Copied Clerk theme to Android assets'); | ||
| return config; | ||
| }, | ||
| ]); | ||
|
|
||
| return config; | ||
| }; | ||
|
|
||
| const withClerkExpo = (config, props = {}) => { | ||
| const { appleSignIn = true } = props; | ||
| config = withClerkIOS(config); | ||
|
|
@@ -594,6 +708,7 @@ const withClerkExpo = (config, props = {}) => { | |
| config = withClerkGoogleSignIn(config); | ||
| config = withClerkAndroid(config); | ||
| config = withClerkKeychainService(config, props); | ||
| config = withClerkTheme(config, props); | ||
| return config; | ||
| }; | ||
|
|
||
|
|
||
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.
Schema validator accepts invalid object shapes (merge-blocking)
validateThemeJsondoes not enforce object types forcolors,darkColors, anddesign(e.g., Line 625 returns early for non-objects). That allows malformed config to pass prebuild validation and then fail or no-op at native runtime.Proposed fix
function validateThemeJson(theme) { + if (!theme || typeof theme !== 'object' || Array.isArray(theme)) { + throw new Error(`Clerk theme: root theme must be an object`); + } + const validateColors = (colors, label) => { - if (!colors || typeof colors !== 'object') return; + if (colors == null) return; + if (typeof colors !== 'object' || Array.isArray(colors)) { + throw new Error(`Clerk theme: ${label} must be an object`); + } for (const [key, value] of Object.entries(colors)) { if (!VALID_COLOR_KEYS.includes(key)) { console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); continue; } if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); } } }; if (theme.colors) validateColors(theme.colors, 'colors'); if (theme.darkColors) validateColors(theme.darkColors, 'darkColors'); - if (theme.design) { + if (theme.design != null) { + if (typeof theme.design !== 'object' || Array.isArray(theme.design)) { + throw new Error(`Clerk theme: design must be an object`); + } if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { throw new Error(`Clerk theme: design.fontFamily must be a string`); } if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { throw new Error(`Clerk theme: design.borderRadius must be a number`); } } }As per coding guidelines, “Only comment on issues that would block merging, ignore minor or stylistic concerns.”
🤖 Prompt for AI Agents