MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Apps that feel native to Android get better ratings and more long-term users. Here's how to implement Material 3 correctly in Compose — theming, components, motion, and the design decisions that separate apps that feel polished from ones that feel amateur.
On this page
Users judge app quality in seconds. A Material 3 app that follows platform conventions feels instantly trustworthy. One that ignores them feels alien, even if the functionality is identical.
Here's how to build apps that feel right on Android.
// build.gradle.kts
implementation("androidx.compose.material3:material3")Wrap your app with
MaterialTheme@Composable
fun MyApp() {
MaterialTheme(
colorScheme = LightColorScheme,
typography = AppTypography,
shapes = AppShapes
) {
AppNavHost()
}
}Android 12+ supports dynamic color — the app's color scheme adapts to the user's wallpaper:
@Composable
fun MyApp() {
val context = LocalContext.current
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
isSystemInDarkTheme() -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(colorScheme = colorScheme) {
AppNavHost()
}
}Dynamic color is one of the most visible "this app belongs on Android" signals. Implement it.
Material 3 has specific color roles. Use them semantically:
// DO use semantic roles — they adapt to light/dark and dynamic color
Text(
text = "Primary content",
color = MaterialTheme.colorScheme.onSurface
)
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
// DON'T hardcode colors — they don't adapt
Text(
text = "Primary content",
color = Color(0xFF1A1A1A) // Breaks in dark mode
)Key color pairs:
primaryonPrimarysurfaceonSurfaceerroronErrorsurfaceVariantonSurfaceVariantDon't invent your own text sizes. Material 3 has a defined type scale:
// Material 3 type scale — use these consistently
Text(text = title, style = MaterialTheme.typography.headlineMedium)
Text(text = body, style = MaterialTheme.typography.bodyMedium)
Text(text = caption, style = MaterialTheme.typography.labelSmall)
Text(text = buttonText, style = MaterialTheme.typography.labelLarge)Customize the type scale in
AppTypographyval AppTypography = Typography(
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)Before building a custom component, check if Material 3 has one that fits:
| Need | Material 3 Component |
|---|---|
| Primary action | code code |
| Secondary action | code code |
| Input field | code code |
| Top navigation | code code |
| Bottom navigation | code |
| Card/container | code code code |
| List item | code |
| Selection | code code code |
| Chips/tags | code code code |
| Dialogs | code code |
| Sheets | code |
| FAB | code code |
Using standard components means users know how they work. Custom components have a learning cost.
Material 3 uses "tonal elevation" — surfaces at higher elevation appear lighter (in dark mode) or more saturated:
// ElevatedCard naturally handles elevation tinting
ElevatedCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp)
) {
// Content
}
// Surface with explicit elevation
Surface(
tonalElevation = 3.dp,
shape = MaterialTheme.shapes.medium
) {
// Content
}Don't use drop shadows to simulate elevation — Material 3 uses tonal elevation (color change) not drop shadows.
Material 3 has a shape system with five sizes:
val AppShapes = Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(28.dp)
)
// Use in composables
Surface(shape = MaterialTheme.shapes.medium) { ... }Material You apps use the same shape scale throughout. Consistency is what makes apps feel polished, not visual complexity.
Compose provides built-in animation APIs that match Material 3 motion guidelines:
// Visibility changes with animation
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
ExpandedContent()
}
// Animated content change
AnimatedContent(
targetState = currentTab,
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
}
) { tab ->
when (tab) {
Tab.Tasks -> TaskListContent()
Tab.Calendar -> CalendarContent()
}
}
// Smooth value transitions
val elevation by animateDpAsState(
targetValue = if (isScrolled) 4.dp else 0.dp,
label = "header_elevation"
)Subtle animation on content changes, list insertions, and state transitions is what separates "good" from "polished."
Handle dark mode with the theme, not with conditional code:
// Automatically correct in light and dark
Icon(
imageVector = Icons.Default.Favorite,
tint = MaterialTheme.colorScheme.primary // Correct in both modes
)
// Conditionally adjust only for non-color-scheme content
val iconAsset = if (isSystemInDarkTheme()) {
painterResource(R.drawable.logo_light) // Logo variant for dark mode
} else {
painterResource(R.drawable.logo_dark)
}onSurfaceprimarySudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read