Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .changeset/expo-native-component-theming.md
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
Expand Up @@ -275,7 +275,7 @@ class ClerkAuthActivity : ComponentActivity() {
// Client is ready, show AuthView
AuthView(
modifier = Modifier.fillMaxSize(),
clerkTheme = null // Use default theme, or pass custom
clerkTheme = Clerk.customTheme
)
}
else -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
) {
AuthView(
modifier = Modifier.fillMaxSize(),
clerkTheme = null
clerkTheme = Clerk.customTheme
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.ClerkResult
import com.clerk.api.ui.ClerkColors
import com.clerk.api.ui.ClerkDesign
import com.clerk.api.ui.ClerkTheme
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
Expand All @@ -18,6 +23,7 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.json.JSONObject

private const val TAG = "ClerkExpoModule"

Expand Down Expand Up @@ -79,6 +85,9 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
}

Clerk.initialize(reactApplicationContext, pubKey)
// Must be set AFTER Clerk.initialize() because initialize()
// resets customTheme to its `theme` parameter (default null).
loadThemeFromAssets()

// Wait for initialization to complete with timeout
try {
Expand Down Expand Up @@ -367,4 +376,83 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :

promise.resolve(result)
}

// MARK: - Theme Loading

private fun loadThemeFromAssets() {
try {
val jsonString = reactApplicationContext.assets
.open("clerk_theme.json")
.bufferedReader()
.use { it.readText() }
val json = JSONObject(jsonString)
Clerk.customTheme = parseClerkTheme(json)
} catch (e: java.io.FileNotFoundException) {
// No theme file provided — use defaults
} catch (e: Exception) {
debugLog(TAG, "Failed to load clerk_theme.json: ${e.message}")
}
}

private fun parseClerkTheme(json: JSONObject): ClerkTheme {
val colors = json.optJSONObject("colors")?.let { parseColors(it) }
val darkColors = json.optJSONObject("darkColors")?.let { parseColors(it) }
val design = json.optJSONObject("design")?.let { parseDesign(it) }
return ClerkTheme(
colors = colors,
darkColors = darkColors,
design = design
)
}

private fun parseColors(json: JSONObject): ClerkColors {
return ClerkColors(
primary = json.optStringColor("primary"),
background = json.optStringColor("background"),
input = json.optStringColor("input"),
danger = json.optStringColor("danger"),
success = json.optStringColor("success"),
warning = json.optStringColor("warning"),
foreground = json.optStringColor("foreground"),
mutedForeground = json.optStringColor("mutedForeground"),
primaryForeground = json.optStringColor("primaryForeground"),
inputForeground = json.optStringColor("inputForeground"),
neutral = json.optStringColor("neutral"),
border = json.optStringColor("border"),
ring = json.optStringColor("ring"),
muted = json.optStringColor("muted"),
shadow = json.optStringColor("shadow")
)
}

private fun parseDesign(json: JSONObject): ClerkDesign {
return if (json.has("borderRadius")) {
ClerkDesign(borderRadius = json.getDouble("borderRadius").toFloat().dp)
} else {
ClerkDesign()
}
}

private fun parseHexColor(hex: String): Color? {
val cleaned = hex.removePrefix("#")
return try {
when (cleaned.length) {
6 -> Color(android.graphics.Color.parseColor("#FF$cleaned"))
// Theme JSON uses RRGGBBAA; Android parseColor expects AARRGGBB
8 -> {
val rrggbb = cleaned.substring(0, 6)
val aa = cleaned.substring(6, 8)
Color(android.graphics.Color.parseColor("#$aa$rrggbb"))
}
else -> null
}
} catch (e: Exception) {
null
}
}

private fun JSONObject.optStringColor(key: String): Color? {
val value = optString(key, null) ?: return null
return parseHexColor(value)
}
}
115 changes: 115 additions & 0 deletions packages/expo/app.plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
}
}
Comment on lines +623 to +648
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Schema validator accepts invalid object shapes (merge-blocking)

validateThemeJson does not enforce object types for colors, darkColors, and design (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
Verify each finding against the current code and only fix it if needed.

In `@packages/expo/app.plugin.js` around lines 623 - 648, validateThemeJson
currently allows non-object shapes for theme.colors, theme.darkColors, and
theme.design because validateColors returns early for non-objects; change the
validation to be strict: in validateColors throw an Error when colors is present
but not an object (use typeof === 'object' && colors !== null &&
!Array.isArray(colors)), and for theme.design require it to be an object when
present (throw if design is not an object or is an array/null); keep existing
checks for VALID_COLOR_KEYS and HEX_COLOR_REGEX and the
design.fontFamily/design.borderRadius type checks, but ensure the initial
presence checks for theme.colors, theme.darkColors, and theme.design validate
object shape and error out instead of silently returning.


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');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Something codex's local review spotted on this:

darkColors changes the whole app's appearance policy
When darkColors is present, this deletes UIUserInterfaceStyle from Info.plist. That is an app-wide setting, not a Clerk-only one, so any app intentionally pinned to Light or Dark will silently switch back to system-controlled appearance just to theme Clerk screens.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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);
Expand All @@ -594,6 +708,7 @@ const withClerkExpo = (config, props = {}) => {
config = withClerkGoogleSignIn(config);
config = withClerkAndroid(config);
config = withClerkKeychainService(config, props);
config = withClerkTheme(config, props);
return config;
};

Expand Down
Loading
Loading