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 {