Featured
Tenant-Specific Theming with Material 3 in Jetpack Compose
As mobile apps scale and serve diverse user bases, tenant-specific theming becomes increasingly important — especially for B2B platforms that support white-label branding. In a recent investigation, I explored how we can implement dynamic, theme-based color customization per tenant in our Android app using Jetpack Compose and Material 3.
The good news? It’s feasible, elegant, and aligns perfectly with the theming system provided by Material 3 in Compose.
Problem Statement
Our app needs to support multiple tenants, each with their own branding colors for light and dark modes. Rather than hardcoding specific colors like red
or green
, we want to define and use abstract semantic colors like primary
, secondary
, error
, etc.
This way, the app remains consistent, scalable, and maintainable as tenant configurations evolve.
Proposed Solution
We define abstract color roles (primary, secondary, error, etc.) per tenant, for both light and dark themes. Once the UX team finalizes the colors, we’ll apply them dynamically at runtime using Jetpack Compose theming capabilities.
💡 Benefits
- Clean UI Code: Abstracting color names decouples design from implementation.
- Dynamic Brand Identity: Each tenant gets a unique look and feel.
- Full Light & Dark Theme Support: Each theme has its own configuration.
- Easy Theme Switching: Can be done at runtime based on tenant config or user preference.
Implementation Guide
- Define a
TenantColors
Data Class
data class TenantColors(
val primary: Color,
val onPrimary: Color,
val secondary: Color,
val onSecondary: Color,
val error: Color,
val onError: Color,
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
)
2. Provide Light & Dark Color Sets per Tenant
val tenantA_lightColors = TenantColors(
primary = Color(0xFF1E88E5),
onPrimary = Color.White,
secondary = Color(0xFF26A69A),
onSecondary = Color.Black,
error = Color(0xFFD32F2F),
onError = Color.White,
background = Color.White,
onBackground = Color.Black,
surface = Color(0xFFF5F5F5),
onSurface = Color.Black,
)
val tenantA_darkColors = TenantColors(
primary = Color(0xFF90CAF9),
onPrimary = Color.Black,
secondary = Color(0xFF80CBC4),
onSecondary = Color.Black,
error = Color(0xFFEF9A9A),
onError = Color.Black,
background = Color(0xFF121212),
onBackground = Color.White,
surface = Color(0xFF1E1E1E),
onSurface = Color.White,
)
3. Convert to Material3 ColorScheme
fun TenantColors.toColorScheme(isDark: Boolean): ColorScheme {
return ColorScheme(
primary = primary,
onPrimary = onPrimary,
secondary = secondary,
onSecondary = onSecondary,
error = error,
onError = onError,
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onSurface,
brightness = if (isDark) Brightness.Dark else Brightness.Light
)
}
4. Default Colors Before Backend Loads
Before fetching tenant-specific colors, the app shows a default fallback theme.
What the user sees:
- A clean, neutral UI.
- Uses standard Material 3 fallback colors like: Purple for primary, Teal for secondary and Red for error
- Ensures visual consistency before dynamic colors load.
Default fallback:
val defaultColors = TenantColors(
primary = Color(0xFF6200EE),
onPrimary = Color.White,
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
error = Color(0xFFB00020),
onError = Color.White,
background = Color.White,
onBackground = Color.Black,
surface = Color(0xFFF0F0F0),
onSurface = Color.Black,
)
This fallback theme can also represent the app’s default brand or splash theme.
5. Compose App Theme Wrapper
@Composable
fun AppTheme(
tenantColors: TenantColors,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = tenantColors.toColorScheme(darkTheme)
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}
6. Applying the Theme in Your App
@Composable
fun MyApp() {
val viewModel: ThemeViewModel = viewModel()
val tenantColors by viewModel.tenantColors.collectAsState(initial = defaultColors)
val isLoading by viewModel.isLoading.collectAsState()
AppTheme(tenantColors = tenantColors) {
if (isLoading) {
SplashScreen()
} else {
NavigationHost()
}
}
}
7. UI Components Use Abstract Colors
Button(
onClick = { /*...*/ },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Continue")
}
Unit Testing the Themes
- Color Mapping Unit Test
class ThemeMappingTest {
@Test
fun `verify light theme color mapping for tenant A`() {
val scheme = tenantA_lightColors.toColorScheme(isDark = false)
assertEquals(Color(0xFF1E88E5), scheme.primary)
assertEquals(Color(0xFF26A69A), scheme.secondary)
assertEquals(Color(0xFFF5F5F5), scheme.surface)
}
@Test
fun `verify fallback theme is applied initially`() {
val fallback = defaultColors.toColorScheme(false)
assertEquals(Color(0xFF6200EE), fallback.primary)
assertEquals(Color.White, fallback.onPrimary)
}
}
2. Compose UI Test
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun appTheme_appliesCorrectPrimaryColor() {
composeTestRule.setContent {
AppTheme(tenantColors = tenantA_lightColors) {
Box(
modifier = Modifier
.testTag("primaryBox")
.background(MaterialTheme.colorScheme.primary)
)
}
}
composeTestRule.onNodeWithTag("primaryBox")
.assertExists()
}
Integration Tips
- Use ViewModel or Repository pattern to fetch and manage tenant theme config.
- Integrate with Firebase Remote Config or backend API for dynamic updates.
- Cache tenant colors in DataStore or Room for offline and instant startup.
- Cover with unit tests to verify color mapping and fallback logic.
Final Thoughts
Material 3 and Jetpack Compose provide a powerful and flexible system for managing UI theming. By abstracting our color system and plugging in tenant-specific configurations, we can deliver a tailored user experience that aligns with each brand — without compromising on code clarity or maintainability.
Including a fallback theme ensures there’s no visual gap or inconsistency while the backend loads the actual theme, giving users a seamless and professional experience from the start.
📌 Reference:
Theming in Compose with Material 3 | Android Developers