
You checked the logs. The data is there. The modifiers are perfect. So why is your screen empty? A deep dive into the clash between custom branding and Material 3 Dynamic Theming.
If you’ve been building Android apps in Jetpack Compose for long enough, you’ve inevitably hit the “Phantom UI” bug.
You fire up your app, navigate to a meticulously crafted screen, and… nothing. A UI element is completely missing. You aggressively sprinkle Log.d() statements throughout your ViewModel. The data is definitely there. You check the Layout Inspector. The Compose nodes are taking up exact physical space on the screen.
So why can’t you see it?
Welcome to the dark side of theming in Jetpack Compose. Today, we’re going to dissect a very real, very frustrating edge case where Material 3’s dynamic color system actively fights against your app’s custom design language — resulting in UI elements that perfectly camouflage themselves into your background.
🏗️ The Setup: Mixing Static and Dynamic Colors
Let’s look at a real-world scenario. Imagine you’re building an apartment management app. You need a small, dark green “pill” to display a resident’s flat number (e.g., “Block A 101”).
Your designer gives you a specific, brand-approved dark green for the background. Being a responsible developer, you set up a custom theme extension to hold these brand colors (statusDarkGreen).
Then you build the UI:
Row(
modifier = Modifier
.background(extra.statusDarkGreen, RoundedCornerShape(12.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Icon(
painter = painterResource(id = R.drawable.ic_building),
tint = MaterialTheme.colorScheme.onPrimary, // ⚠️ The trap is set
contentDescription = null
)
Text(
text = "Block A 101",
color = MaterialTheme.colorScheme.onPrimary // ⚠️ The trap is sprung
)
}
You run the app. The green pill renders. The text and icon? Gone.
🔍 Root Cause Analysis: The Contrast Collision
The root cause isn’t a rendering bug — it’s a logic bug in how we interpret Material 3.
Material Design 3 operates on a system of tonal palettes and contrasting pairs. Every background color has an explicitly paired foreground color:
- primary → onPrimary
- surface → onSurface
- secondary → onSecondary
When you call MaterialTheme.colorScheme.onPrimary, you’re not asking Compose for “white text.” You’re asking it for: “Whatever color contrasts perfectly against my theme’s current Primary color.”
If your app’s primary is a bright, light color (like vibrant yellow or cyan), Compose’s internal logic dictates that onPrimary must be dark — like dark gray or black — to maintain accessibility.
Here’s where the streams cross:
- ✅ Background → static, dark custom color (statusDarkGreen)
- ❌ Foreground → dynamic onPrimary, calculated to contrast a completely different color
- 💀 Result → dark text on a dark background. Zero contrast. Invisibility.
🌍 Real-World Edge Cases & Case Studies
This bug is particularly insidious because it often passes initial testing. Here’s how it manifests in the wild.
Case Study 1: The Dark Mode Betrayal
A junior developer hardcodes a light green background (Color(0xFFE8F5E9)) and uses MaterialTheme.colorScheme.onSurface for text.
Mode onSurface value Result Light Black (surface = white) ✅ Readable Dark White (surface = dark) ❌ White text on light green
QA passes it in light mode. Customer support tickets spike every night at 8:00 PM when users’ phones auto-switch to dark mode.
Case Study 2: The OEM “Material You” Override
You rely on MaterialTheme.colorScheme.onPrimary for text on top of a static blue header. It works flawlessly on every company-issued test device.
Then a user with a Google Pixel installs your app. They have Material You enabled with a custom wallpaper. The OS aggressively overrides onPrimary to deep navy blue — matching their wallpaper. Your text vanishes against your static blue header, but only for that one specific user.
🔧 The Fix: Stop Hardcoding, Start Pairing
The knee-jerk reaction is Color.White:
// ❌ The Quick Fix — Don't do this
Text(text = "Block A 101", color = Color.White)
This fixes the symptom, not the disease. When your designer updates the palette, you’ll be hunting down Color.White across your entire codebase.
✅ The Right Fix: Introduce Contrasting Pairs
Treat custom colors with the same respect Material 3 treats its own. Every custom background color needs its contrasting foreground sibling.
Using CompositionLocalProvider with an AppExtraColors class (the gold standard for Compose theming):
data class AppExtraColors(
val statusDarkGreen: Color,
val onStatusDarkGreen: Color // 🦸 The hero we need
)
// In your Light/Dark theme initialization:
private val AppLightExtraColors = AppExtraColors(
statusDarkGreen = Color(0xFF3BAF04),
onStatusDarkGreen = Color.White // Explicitly defined contrasting pair
)
Now your UI code is semantic, safe, and immune to dynamic color overrides:
Text(
text = "Block A 101",
color = extra.onStatusDarkGreen // ✅ Clean, safe, and scales flawlessly
)
🎯 The Takeaway
Jetpack Compose gives us immense power — but with that power comes the responsibility of understanding why the APIs behave the way they do.
Whenever you mix static branding colors with dynamic Material themes, you’re playing with fire.
Audit your Compose files today. Look for any Text or Icon sitting on top of a custom, hardcoded background that uses an on… dynamic color. Fix it now, before a user’s wallpaper renders your app unusable.
Your future self — and your customer support team — will thank you.
Found this useful? Follow for more deep dives into Jetpack Compose architecture and Android engineering best practices.
The Case of the Vanishing Text: Why Your Jetpack Compose UI is Gaslighting You was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments