Skip to content
Merged
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`; on Android the JSON is copied into `android/app/src/main/assets/clerk_theme.json`. The plugin does not modify your app's `userInterfaceStyle` setting — control light/dark mode via `"userInterfaceStyle"` in `app.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,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
}

Clerk.initialize(reactApplicationContext, pubKey)
// Theme loading is centralized here. ClerkViewFactory.configure()
// and ClerkUserProfileActivity.onCreate() only call Clerk.initialize()
// when Clerk is not yet initialized, so by the time they run
// ClerkExpoModule has already set the custom theme.
// Must be set AFTER Clerk.initialize() because initialize()
// resets customTheme to its `theme` parameter (default null).
loadThemeFromAssets()
Comment thread
manovotny marked this conversation as resolved.

// Wait for initialization to complete with timeout
try {
Expand Down Expand Up @@ -367,4 +380,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)
}
}
119 changes: 119 additions & 0 deletions packages/expo/app.plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,123 @@ const withClerkAppleSignIn = config => {
});
};

/**
* Apply a custom theme to Clerk native components (iOS + Android).
Comment thread
manovotny marked this conversation as resolved.
*
* 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".
* 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 isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function validateThemeJson(theme) {
if (!isPlainObject(theme)) {
throw new Error('Clerk theme: theme JSON must be a plain object');
}

const validateColors = (colors, label) => {
if (!isPlainObject(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 != null) validateColors(theme.colors, 'colors');
if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors');

if (theme.design != null) {
if (!isPlainObject(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`);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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');
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,7 +711,9 @@ const withClerkExpo = (config, props = {}) => {
config = withClerkGoogleSignIn(config);
config = withClerkAndroid(config);
config = withClerkKeychainService(config, props);
config = withClerkTheme(config, props);
return config;
};

module.exports = withClerkExpo;
module.exports._testing = { validateThemeJson, isPlainObject, VALID_COLOR_KEYS, HEX_COLOR_REGEX };
Loading
Loading