Skip to content
All posts
June 3, 20264 min read

Designing for Android Compose: Material 3 Patterns That Feel Native

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.

AndroidJetpack ComposeUX
Share:

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.


Setting Up Material 3

kotlin
// build.gradle.kts
implementation("androidx.compose.material3:material3")

Wrap your app with

code
MaterialTheme
:

kotlin
@Composable
fun MyApp() {
    MaterialTheme(
        colorScheme = LightColorScheme,
        typography = AppTypography,
        shapes = AppShapes
    ) {
        AppNavHost()
    }
}

Dynamic Color (Material You)

Android 12+ supports dynamic color — the app's color scheme adapts to the user's wallpaper:

kotlin
@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.


The Color Roles That Matter

Material 3 has specific color roles. Use them semantically:

kotlin
// 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:

  • code
    primary
    /
    code
    onPrimary
    — primary actions, buttons
  • code
    surface
    /
    code
    onSurface
    — cards, sheets, text on surfaces
  • code
    error
    /
    code
    onError
    — error states
  • code
    surfaceVariant
    /
    code
    onSurfaceVariant
    — secondary content, captions

Typography: Use the Type Scale

Don't invent your own text sizes. Material 3 has a defined type scale:

kotlin
// 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

code
AppTypography
:

kotlin
val AppTypography = Typography(
    bodyMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    )
)

Components: Use Material 3, Not Custom

Before building a custom component, check if Material 3 has one that fits:

NeedMaterial 3 Component
Primary action
code
Button
,
code
FilledTonalButton
Secondary action
code
OutlinedButton
,
code
TextButton
Input field
code
OutlinedTextField
,
code
TextField
Top navigation
code
TopAppBar
,
code
CenterAlignedTopAppBar
Bottom navigation
code
NavigationBar
Card/container
code
Card
,
code
ElevatedCard
,
code
OutlinedCard
List item
code
ListItem
Selection
code
Checkbox
,
code
RadioButton
,
code
Switch
Chips/tags
code
FilterChip
,
code
InputChip
,
code
AssistChip
Dialogs
code
AlertDialog
,
code
BasicAlertDialog
Sheets
code
ModalBottomSheet
FAB
code
FloatingActionButton
,
code
SmallFloatingActionButton

Using standard components means users know how they work. Custom components have a learning cost.


Elevation and Surfaces

Material 3 uses "tonal elevation" — surfaces at higher elevation appear lighter (in dark mode) or more saturated:

kotlin
// 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.


Shapes

Material 3 has a shape system with five sizes:

kotlin
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.


Motion: The Overlooked Layer

Compose provides built-in animation APIs that match Material 3 motion guidelines:

kotlin
// 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."


Dark Mode

Handle dark mode with the theme, not with conditional code:

kotlin
// 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)
}

Takeaways

  • Dynamic color (Material You) is table stakes for Android 12+ — implement it
  • Use semantic color roles (
    code
    onSurface
    ,
    code
    primary
    ) — never hardcode hex colors
  • Use Material 3's type scale — consistency is more important than creativity
  • Use standard Material 3 components before building custom ones
  • Tonal elevation (color change) is Material 3 — not drop shadows
  • Subtle motion (AnimatedVisibility, AnimatedContent) transforms good UX into great UX
Share:
S

Sudarshan 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.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

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.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this