Material 3 in Compose: a practical Android & Kotlin guide
By day 22 of your Modern Android & Kotlin development journey, you've built composable functions and navigated between screens. Now it's time to make your app look and feel polished using Material 3—Google's latest design system that brings cohesion, accessibility, and beautiful aesthetics to Android. We'll walk through how to set up Material 3 in Compose, customize your brand identity with color schemes, and leverage the design tokens that ship with the Material library to build consistent, production-grade UI.
Material 3 introduces a shift from the previous Material Design 2 approach: instead of rigid color palettes, you get dynamic theming powered by color harmonization algorithms. On Android 12 and later, your app can extract colors directly from the device's wallpaper—a feature that delights users and reduces your design workload. In this guide, we'll explore MaterialTheme with color schemes, dynamic color on modern Android versions, the Scaffold composable for layout structure, and the typography and shapes tokens that tie everything together.
MaterialTheme with color schemes
MaterialTheme is the root composable that applies Material 3 styling across your app. It provides a ColorScheme that defines all the semantic colors your components will use: primary, secondary, tertiary, background, surface, error, and their variants.
Let's set up a basic Material 3 theme in your main activity:
package com.example.myapp.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005E),
secondary = Color(0xFF625B71),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
tertiary = Color(0xFF7D5260),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFFFD8E4),
onTertiaryContainer = Color(0xFF31111D),
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFFFFBFE),
onSurface = Color(0xFF1C1B1F),
error = Color(0xFFB3261E),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
)
@Composable
fun MyAppTheme(
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = LightColorScheme,
content = content
)
}
Each color in the ColorScheme serves a specific purpose. The primary color is your brand's main accent; onPrimary is the text or icon color that appears on top of primary. The Container variants (like primaryContainer) provide softer backgrounds for components like filled buttons, while their on counterparts ensure readable text. This semantic approach means you change your color scheme once and all components adapt automatically.
In a production e-commerce app, you'd generate these colors using the Material Color Utilities library—typically based on your brand guidelines—then export them to Kotlin code. This keeps your design system synchronized across web, iOS, and Android.
Dynamic color on Android 12+
Android 12 introduced the ability to extract a dynamic color scheme from the user's wallpaper. Material 3 makes this trivial with the dynamicColorScheme function. On older devices, you fall back to your static theme.
Here's how to implement it:
package com.example.myapp.ui.theme
import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}
The dynamicLightColorScheme() and dynamicDarkColorScheme() functions extract colors from the system wallpaper using the Material Color Utilities algorithm. This creates a harmonious, personalized theme for each user without you writing a single color manually. On Android 11 and below, the code safely falls back to your predefined LightColorScheme or DarkColorScheme.
In a real-world SaaS dashboard, you might allow users to disable dynamic color in their preferences, respecting their desire for a consistent brand experience. Always check Build.VERSION.SDK_INT before calling dynamic functions to avoid crashes on older devices.
Scaffold for app bars and FABs
Scaffold is a composable that provides Material 3's standard app structure: a top app bar, floating action button (FAB), bottom navigation, and a content area. It handles layout alignment and spacing so you focus on your screen's business logic.
Let's build a typical e-commerce product list screen with a top app bar and a FAB to add items to a cart:
package com.example.myapp.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProductListScreen() {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Products") },
navigationIcon = {
IconButton(onClick = { /* Open drawer */ }) {
Icon(Icons.Filled.Menu, "Open navigation")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { /* Navigate to cart */ }) {
Icon(Icons.Filled.Add, "Add to cart")
}
}
) { innerPadding ->
ProductList(modifier = Modifier.padding(innerPadding))
}
}
@Composable
fun ProductList(modifier: Modifier = Modifier) {
// Your product list content here
}
The Scaffold accepts a lambda that receives innerPadding—essential spacing that avoids content sliding under the app bar or behind the FAB. Notice the @OptIn(ExperimentalMaterial3Api::class) annotation; TopAppBar is experimental in some Material 3 versions, so you acknowledge that its API may change between library releases.
Scaffold also supports bottomBar, snackbarHost, and containerColor` for full-screen layouts. In a ride-hailing app, you'd use Scaffold to house a driver map with a bottom sheet for ride requests, keeping all layers properly organized.
Typography and shapes tokens
Typography and Shapes are Material 3's design tokens—reusable abstractions that ensure consistency across your app. Material 3 defines a type scale with six levels (displayLarge, headlineSmall, bodyMedium, etc.) and shape tokens for rounded corners (small, medium, large).
Create a custom typography file:
package com.example.myapp.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val CustomFontFamily = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_bold, FontWeight.Bold),
)
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = CustomFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
headlineSmall = TextStyle(
fontFamily = CustomFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
bodyMedium = TextStyle(
fontFamily = CustomFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
labelSmall = TextStyle(
fontFamily = CustomFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)
And define shape tokens:
package com.example.myapp.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
)
Now apply them throughout your app by accessing MaterialTheme.typography and MaterialTheme.shapes:
@Composable
fun ProductCard(product: Product) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = product.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = product.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
By using design tokens instead of hardcoded values, you ensure that changing your brand's typography or corner radius updates every component automatically. This scales beautifully in large teams where designers and engineers work in parallel.
Common mistakes
- Ignoring innerPadding in Scaffold. Forgetting to apply
innerPaddingfrom Scaffold's content lambda causes your content to overlap the app bar or FAB. Always destructure and apply it to your root modifier. - Hardcoding colors outside MaterialTheme. Don't sprinkle
Color(0xFF6750A4)throughout your codebase. Always useMaterialTheme.colorScheme.primaryso theme changes propagate everywhere. - Overriding dynamic color without a reason. Material 3's dynamic color delights users. Only disable it if your brand demands a locked color palette; for most apps, let dynamic color adapt to the user's device.
- Mixing Material 2 and Material 3 components. Don't import
Buttonfromandroidx.compose.materialif you're using Material 3 elsewhere. Useandroidx.compose.material3.Buttonexclusively to maintain consistency. - Not testing on devices older than Android 12. Always verify that your theme gracefully degrades on older OS versions. Use an emulator or test device running Android 11 to catch fallback issues early.
Key takeaways
- MaterialTheme with a ColorScheme gives you semantic colors (primary, secondary, error, etc.) that automatically propagate to all Material 3 components.
- Dynamic color on Android 12+ extracts wallpaper colors, creating a personalized theme without extra work; always provide static fallback schemes for older devices.
- Scaffold structures your layout with top/bottom bars and FAB, while innerPadding ensures content doesn't overlap chrome elements.
- Typography and Shapes tokens replace hardcoded text styles and corner radii, centralizing your design system in code and making brand updates effortless.
- Prefer
MaterialTheme.colorScheme,MaterialTheme.typography, andMaterialTheme.shapesover hardcoded values to guarantee theme consistency across your entire app.






Comments
Loading comments…