Skip to content
Draft
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
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 @@ -78,6 +84,7 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
.apply()
}

loadThemeFromAssets()
Clerk.initialize(reactApplicationContext, pubKey)

// Wait for initialization to complete with timeout
Expand Down Expand Up @@ -367,4 +374,77 @@ 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 ClerkDesign(
fontFamily = json.optString("fontFamily", null),
borderRadius = if (json.has("borderRadius")) json.getDouble("borderRadius").dp else null
)
}

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)
}
}
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]{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);
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