diff --git a/.changeset/expo-native-component-theming.md b/.changeset/expo-native-component-theming.md
new file mode 100644
index 00000000000..bbb82c15fa4
--- /dev/null
+++ b/.changeset/expo-native-component-theming.md
@@ -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 (``, ``, ``) 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`.
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
index acd934830de..1c8049adba6 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt
@@ -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 -> {
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
index 60280542e27..a479e205085 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt
@@ -105,7 +105,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
) {
AuthView(
modifier = Modifier.fillMaxSize(),
- clerkTheme = null
+ clerkTheme = Clerk.customTheme
)
}
}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
index e4d15f6a963..c93a70462ac 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
@@ -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
@@ -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"
@@ -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 {
@@ -367,4 +376,78 @@ 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"))
+ 8 -> Color(android.graphics.Color.parseColor("#$cleaned"))
+ else -> null
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ private fun JSONObject.optStringColor(key: String): Color? {
+ val value = optString(key, null) ?: return null
+ return parseHexColor(value)
+ }
}
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index 758e80b5692..97a8b5a5ea1 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -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]{3}|[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');
+ }
+
+ 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;
};
diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift
index 38b64c29edb..f46d0134dcd 100644
--- a/packages/expo/ios/ClerkViewFactory.swift
+++ b/packages/expo/ios/ClerkViewFactory.swift
@@ -18,6 +18,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
private static let clerkLoadIntervalNs: UInt64 = 100_000_000
private static var clerkConfigured = false
+ /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary.
+ var lightTheme: ClerkTheme?
+ var darkTheme: ClerkTheme?
+
private enum KeychainKey {
static let jsClientJWT = "__clerk_client_jwt"
static let nativeDeviceToken = "clerkDeviceToken"
@@ -43,6 +47,7 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
// Register this factory with the ClerkExpo module
public static func register() {
+ shared.loadThemes()
clerkViewFactory = shared
}
@@ -152,6 +157,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
let wrapper = ClerkAuthWrapperViewController(
mode: Self.authMode(from: mode),
dismissable: dismissable,
+ lightTheme: lightTheme,
+ darkTheme: darkTheme,
completion: completion
)
return wrapper
@@ -163,6 +170,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
) -> UIViewController? {
let wrapper = ClerkProfileWrapperViewController(
dismissable: dismissable,
+ lightTheme: lightTheme,
+ darkTheme: darkTheme,
completion: completion
)
return wrapper
@@ -179,6 +188,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
rootView: ClerkInlineAuthWrapperView(
mode: Self.authMode(from: mode),
dismissable: dismissable,
+ lightTheme: lightTheme,
+ darkTheme: darkTheme,
onEvent: onEvent
)
)
@@ -191,6 +202,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
makeHostingController(
rootView: ClerkInlineProfileWrapperView(
dismissable: dismissable,
+ lightTheme: lightTheme,
+ darkTheme: darkTheme,
onEvent: onEvent
)
)
@@ -226,6 +239,91 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
}
}
+ // MARK: - Theme Parsing
+
+ /// Reads the "ClerkTheme" dictionary from Info.plist and builds light / dark themes.
+ func loadThemes() {
+ guard let themeDictionary = Bundle.main.object(forInfoDictionaryKey: "ClerkTheme") as? [String: Any] else {
+ return
+ }
+
+ // Build light theme from top-level "colors" and "design"
+ let lightColors = (themeDictionary["colors"] as? [String: String]).flatMap { parseColors(from: $0) }
+ let design = (themeDictionary["design"] as? [String: Any]).flatMap { parseDesign(from: $0) }
+ let fonts = (themeDictionary["design"] as? [String: Any]).flatMap { parseFonts(from: $0) }
+
+ if lightColors != nil || design != nil || fonts != nil {
+ lightTheme = ClerkTheme(colors: lightColors, design: design, fonts: fonts)
+ }
+
+ // Build dark theme from "darkColors" (inherits same design/fonts)
+ if let darkColorsDict = themeDictionary["darkColors"] as? [String: String] {
+ let darkColors = parseColors(from: darkColorsDict)
+ if darkColors != nil || design != nil || fonts != nil {
+ darkTheme = ClerkTheme(colors: darkColors, design: design, fonts: fonts)
+ }
+ }
+ }
+
+ private func parseColors(from dict: [String: String]) -> ClerkTheme.Colors? {
+ var hasAny = false
+ var colors = ClerkTheme.Colors()
+
+ if let v = dict["primary"].flatMap({ colorFromHex($0) }) { colors.primary = v; hasAny = true }
+ if let v = dict["background"].flatMap({ colorFromHex($0) }) { colors.background = v; hasAny = true }
+ if let v = dict["input"].flatMap({ colorFromHex($0) }) { colors.input = v; hasAny = true }
+ if let v = dict["danger"].flatMap({ colorFromHex($0) }) { colors.danger = v; hasAny = true }
+ if let v = dict["success"].flatMap({ colorFromHex($0) }) { colors.success = v; hasAny = true }
+ if let v = dict["warning"].flatMap({ colorFromHex($0) }) { colors.warning = v; hasAny = true }
+ if let v = dict["foreground"].flatMap({ colorFromHex($0) }) { colors.foreground = v; hasAny = true }
+ if let v = dict["mutedForeground"].flatMap({ colorFromHex($0) }) { colors.mutedForeground = v; hasAny = true }
+ if let v = dict["primaryForeground"].flatMap({ colorFromHex($0) }) { colors.primaryForeground = v; hasAny = true }
+ if let v = dict["inputForeground"].flatMap({ colorFromHex($0) }) { colors.inputForeground = v; hasAny = true }
+ if let v = dict["neutral"].flatMap({ colorFromHex($0) }) { colors.neutral = v; hasAny = true }
+ if let v = dict["border"].flatMap({ colorFromHex($0) }) { colors.border = v; hasAny = true }
+ if let v = dict["ring"].flatMap({ colorFromHex($0) }) { colors.ring = v; hasAny = true }
+ if let v = dict["muted"].flatMap({ colorFromHex($0) }) { colors.muted = v; hasAny = true }
+ if let v = dict["shadow"].flatMap({ colorFromHex($0) }) { colors.shadow = v; hasAny = true }
+
+ return hasAny ? colors : nil
+ }
+
+ private func colorFromHex(_ hex: String) -> Color? {
+ var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if cleaned.hasPrefix("#") { cleaned.removeFirst() }
+
+ var rgb: UInt64 = 0
+ guard Scanner(string: cleaned).scanHexInt64(&rgb) else { return nil }
+
+ switch cleaned.count {
+ case 6:
+ return Color(
+ red: Double((rgb >> 16) & 0xFF) / 255.0,
+ green: Double((rgb >> 8) & 0xFF) / 255.0,
+ blue: Double(rgb & 0xFF) / 255.0
+ )
+ case 8:
+ return Color(
+ red: Double((rgb >> 24) & 0xFF) / 255.0,
+ green: Double((rgb >> 16) & 0xFF) / 255.0,
+ blue: Double((rgb >> 8) & 0xFF) / 255.0,
+ opacity: Double(rgb & 0xFF) / 255.0
+ )
+ default:
+ return nil
+ }
+ }
+
+ private func parseFonts(from dict: [String: Any]) -> ClerkTheme.Fonts? {
+ guard let fontFamily = dict["fontFamily"] as? String, !fontFamily.isEmpty else { return nil }
+ return ClerkTheme.Fonts(fontFamily: fontFamily)
+ }
+
+ private func parseDesign(from dict: [String: Any]) -> ClerkTheme.Design? {
+ guard let radius = dict["borderRadius"] as? Double else { return nil }
+ return ClerkTheme.Design(borderRadius: CGFloat(radius))
+ }
+
private func makeHostingController(rootView: Content) -> UIViewController {
let hostingController = UIHostingController(rootView: rootView)
hostingController.view.backgroundColor = .clear
@@ -329,9 +427,9 @@ class ClerkAuthWrapperViewController: UIHostingController
private var authEventTask: Task?
private var completionCalled = false
- init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
self.completion = completion
- let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
+ let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme)
super.init(rootView: view)
self.modalPresentationStyle = .fullScreen
subscribeToAuthEvents()
@@ -393,10 +491,20 @@ class ClerkAuthWrapperViewController: UIHostingController
struct ClerkAuthWrapperView: View {
let mode: AuthView.Mode
let dismissable: Bool
+ let lightTheme: ClerkTheme?
+ let darkTheme: ClerkTheme?
+
+ @Environment(\.colorScheme) private var colorScheme
var body: some View {
- AuthView(mode: mode, isDismissable: dismissable)
+ let view = AuthView(mode: mode, isDismissable: dismissable)
.environment(Clerk.shared)
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
+ if let theme {
+ view.environment(\.clerkTheme, theme)
+ } else {
+ view
+ }
}
}
@@ -407,9 +515,9 @@ class ClerkProfileWrapperViewController: UIHostingController?
private var completionCalled = false
- init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
+ init(dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
self.completion = completion
- let view = ClerkProfileWrapperView(dismissable: dismissable)
+ let view = ClerkProfileWrapperView(dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme)
super.init(rootView: view)
self.modalPresentationStyle = .fullScreen
subscribeToAuthEvents()
@@ -454,10 +562,20 @@ class ClerkProfileWrapperViewController: UIHostingController Void
// Track initial session to detect new sign-ins (same approach as Android)
@State private var initialSessionId: String? = Clerk.shared.session?.id
@State private var eventSent = false
+ @Environment(\.colorScheme) private var colorScheme
+
private func sendAuthCompleted(sessionId: String, type: String) {
guard !eventSent, sessionId != initialSessionId else { return }
eventSent = true
onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"])
}
- var body: some View {
- AuthView(mode: mode, isDismissable: dismissable)
+ private var themedAuthView: some View {
+ let view = AuthView(mode: mode, isDismissable: dismissable)
.environment(Clerk.shared)
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
+ return Group {
+ if let theme {
+ view.environment(\.clerkTheme, theme)
+ } else {
+ view
+ }
+ }
+ }
+
+ var body: some View {
+ themedAuthView
// Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach).
// This is more reliable than auth.events which may not emit for inline AuthView sign-ins.
.onChange(of: Clerk.shared.session?.id) { _, newSessionId in
@@ -512,11 +646,24 @@ struct ClerkInlineAuthWrapperView: View {
struct ClerkInlineProfileWrapperView: View {
let dismissable: Bool
+ let lightTheme: ClerkTheme?
+ let darkTheme: ClerkTheme?
let onEvent: (String, [String: Any]) -> Void
+ @Environment(\.colorScheme) private var colorScheme
+
var body: some View {
- UserProfileView(isDismissable: dismissable)
+ let view = UserProfileView(isDismissable: dismissable)
.environment(Clerk.shared)
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
+ let themedView = Group {
+ if let theme {
+ view.environment(\.clerkTheme, theme)
+ } else {
+ view
+ }
+ }
+ themedView
.task {
for await event in Clerk.shared.auth.events {
switch event {