Material 3 and Theming in Compose

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Material 3 is the design system Compose’s material3 library implements, and MaterialTheme is the single composable that supplies it: a color scheme, a type scale and a shape scale, made available to every composable below it through CompositionLocal`s (State, Recomposition and Side Effects in Compose). This page covers theming and the core Material 3 components as of Compose BOM 2026.09.00 (`androidx.compose.material3:material3 1.4.0), building on Jetpack Compose Fundamentals and Compose Layouts, Modifiers and Lazy Lists. The running example is TrailMate's TrailMateTheme and the Scaffold-based shell that hosts TrailListScreen, TrailDetailScreen, SettingsScreen and PhotoCaptureScreen.

This page does not re-teach remember, state hoisting or the effect APIs a themed composable still relies on — see State, Recomposition and Side Effects in Compose for those — and it does not cover NavHost/routes, which Navigation in Compose handles once TrailMateTheme and Scaffold are in place. Readers coming from the classic Views theming model (Views, XML Layouts, View Binding and Material Components) should read Migrating an XML Theme to Compose below, which links to the full phased migration on Migrating from Views to Compose.

MaterialTheme: Color Scheme, Typography and Shapes

MaterialTheme is a composable function taking three inputs — colorScheme, typography and shapes — and exposing each back out through MaterialTheme.colorScheme, MaterialTheme.typography and MaterialTheme.shapes for every composable nested inside it to read:

// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/TrailMateTheme.kt
@Composable
fun TrailMateThemeSkeleton(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = lightColorScheme(primary = Color(0xFF2E6B4F)),
        typography = Typography(),
        shapes = Shapes(),
        content = content,
    )
}

A ColorScheme is a fixed set of named color roles — primary, onPrimary, primaryContainer, secondary, tertiary, surface, surfaceVariant, error, and their on*/*Container pairs — rather than a raw palette; every Material 3 component reads its colors from these roles, never from a literal Color value, so recoloring the whole app means constructing a different ColorScheme and nothing else. lightColorScheme() and darkColorScheme() build one from a handful of seed colors, defaulting every role Google’s Material color system derives from them; TrailMate overrides only the roles that matter to its brand (primary, primaryContainer) and lets the rest default:

// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Color.kt
val TrailMateLightColors = lightColorScheme(
    primary = Color(0xFF2E6B4F),
    onPrimary = Color(0xFFFFFFFF),
    primaryContainer = Color(0xFFB3F0CE),
    onPrimaryContainer = Color(0xFF002112),
    secondary = Color(0xFF4F6354),
    error = Color(0xFFBA1A1A),
)

val TrailMateDarkColors = darkColorScheme(
    primary = Color(0xFF97D5AF),
    onPrimary = Color(0xFF00391F),
    primaryContainer = Color(0xFF15522F),
    onPrimaryContainer = Color(0xFFB3F0CE),
    secondary = Color(0xFFB6CCB9),
    error = Color(0xFFFFB4AB),
)

Generating a complete, accessible role set by hand for both light and dark from a single brand color is tedious and error-prone; TrailMate’s actual values in the sample above were generated with the Material Theme Builder (linked in References) and pasted in as a Color.kt file, rather than picked one role at a time.

Typography is Material 3’s type scale: fifteen named TextStyle`s grouped into five roles — `displayLarge/Medium/Small, headlineLarge/Medium/Small, titleLarge/Medium/Small, bodyLarge/Medium/Small and labelLarge/Medium/Small — read as MaterialTheme.typography.titleLarge, and so on. Overriding one or more styles keeps the rest at Material 3’s defaults:

// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Type.kt
val TrailMateTypography = Typography(
    titleLarge = TextStyle(
        fontFamily = TrailMateFontFamily,
        fontWeight = FontWeight.SemiBold,
        fontSize = 22.sp,
        lineHeight = 28.sp,
    ),
    bodyMedium = TextStyle(
        fontFamily = TrailMateFontFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 20.sp,
    ),
)

Shapes is the third subsystem: five corner-shape sizes — extraSmall, small, medium, large, extraLarge — each component maps itself onto by default (a Card uses medium, a ModalBottomSheet uses extraLarge for its top corners, and so on):

// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Shape.kt
val TrailMateShapes = Shapes(
    extraSmall = RoundedCornerShape(4.dp),
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(16.dp),
    extraLarge = RoundedCornerShape(28.dp),
)

Any component also accepts its own shape/colors/textStyle parameter to deviate from the theme locally — overriding MaterialTheme.shapes.medium on one specific Card — but reading from MaterialTheme by default is what keeps every screen visually consistent without threading colors and text styles through every composable’s parameter list. See Android Developers — Material Design 3 in Compose and Android Developers — androidx.compose.material3 reference.

TrailMateTheme: Light, Dark and Dynamic Color

TrailMate wraps MaterialTheme in its own TrailMateTheme composable, the single entry point every screen calls, which decides which ColorScheme to pass based on the system’s dark-theme setting and, on Android 12+, the user’s wallpaper-derived dynamic color:

// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/TrailMateTheme.kt
@Composable
fun TrailMateTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val context = LocalContext.current
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        darkTheme -> TrailMateDarkColors
        else -> TrailMateLightColors
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = TrailMateTypography,
        shapes = TrailMateShapes,
        content = content,
    )
}

isSystemInDarkTheme() reads the platform’s current day/night configuration reactively — the composable recomposes automatically if the user flips the system setting while TrailMate is open — and is the Compose equivalent of the Views stack’s AppCompatDelegate.setDefaultNightMode / Configuration.uiMode check covered on Views, XML Layouts, View Binding and Material Components. dynamicLightColorScheme(context)/dynamicDarkColorScheme(context), available since API 31 (Android 12), derive a full ColorScheme from the device wallpaper’s extracted colors instead of TrailMate’s own brand palette — Material You personalization — and are guarded behind the Build.VERSION.SDK_INT check because they simply do not exist on older platforms; TrailMate exposes dynamicColor as a parameter (rather than hardcoding it to true) so a SettingsScreen toggle and @Preview functions can force it off and see the brand palette consistently. TrailNavHost (Navigation in Compose) is wrapped in exactly one TrailMateTheme call, at the top of the composable tree, in TrailMateApp:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
@Composable
fun TrailMateApp(userPreferences: UserPreferences) {
    TrailMateTheme(dynamicColor = userPreferences.dynamicColorEnabled) {
        Surface(color = MaterialTheme.colorScheme.background) {
            TrailNavHost()
        }
    }
}

The outer Surface reads MaterialTheme.colorScheme.background explicitly because Surface is what actually paints a solid background behind the content; MaterialTheme itself only provides the color scheme through `CompositionLocal`s, it never paints anything on its own. See Android Developers — Dynamic colors and Android Developers — Dark theme.

Fonts

FontFamily groups one or more Font`s — typically several weights of the same typeface — into the value a `TextStyle.fontFamily expects. TrailMate bundles a variable font as a resource rather than depending on Google’s Downloadable Fonts service, so its type scale renders identically offline and does not depend on a network fetch completing before the first frame:

// core/designsystem/src/main/kotlin/com/example/trailmate/designsystem/Type.kt
val TrailMateFontFamily = FontFamily(
    Font(R.font.manrope_regular, FontWeight.Normal),
    Font(R.font.manrope_medium, FontWeight.Medium),
    Font(R.font.manrope_semibold, FontWeight.SemiBold),
)

FontFamily.Default (the platform’s system sans-serif) is a reasonable starting point and what TrailMateThemeSkeleton above implicitly uses by leaving fontFamily unset; a custom FontFamily like TrailMate’s is what turns a generic Material 3 look into an app’s own visual identity, changed in exactly one place (Type.kt) rather than on every Text call site. A downloadable font — fetched from Google Fonts at install or first run via androidx.compose.ui.text.googlefonts.Font — is the alternative when bundling every weight as a resource would bloat the APK; TrailMate does not use it, trading a slightly larger APK for guaranteed offline availability. See Android Developers — Fonts in Compose.

Scaffold

Scaffold is Material 3’s top-level screen layout: a single composable that reserves the standard slots — a top bar, a bottom bar, a floating action button, a snackbar host and the main content — and measures them so they never overlap, including correctly consuming window insets for each slot. Every TrailMate screen below the navigation host is built on one:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailListScreen(
    onTrailClick: (String) -> Unit,
    onAddTrailClick: () -> Unit,
    viewModel: TrailListViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = {
            TopAppBar(
                title = { Text("Trails") },
                scrollBehavior = scrollBehavior,
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = onAddTrailClick) {
                Icon(Icons.Filled.Add, contentDescription = "Record a new trail")
            }
        },
        snackbarHost = { SnackbarHost(snackbarHostState) },
    ) { innerPadding ->
        TrailListContent(
            uiState = uiState,
            onTrailClick = onTrailClick,
            modifier = Modifier.padding(innerPadding),
        )
    }
}

The lambda Scaffold passes its content is the one place innerPadding is easy to forget: it carries exactly the insets the top bar, bottom bar and system bars already consume, and applying it to the content’s outermost modifier is what stops the list from starting underneath the top app bar.

Top-app-bar scroll behaviors. TopAppBarDefaults exposes several TopAppBarScrollBehavior factories that change how the bar reacts as the content below it scrolls, wired to Scaffold’s content through `Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) exactly as in TrailListScreen above:

Factory Behavior

pinnedScrollBehavior()

The bar never moves or changes size; only its container color can animate as content scrolls underneath. The default when no scrollBehavior is supplied at all.

enterAlwaysScrollBehavior()

The bar scrolls off-screen as the user scrolls down and reappears immediately on the first upward scroll —  TrailMate’s choice for TrailListScreen, so a long trail list gives the map/list content more vertical room.

exitUntilCollapsedScrollBehavior()

For a LargeTopAppBar/MediumTopAppBar: the bar shrinks from its large title down to a compact bar as the user scrolls down, and only expands back on scrolling all the way to the top — used on TrailDetailScreen’s `LargeTopAppBar showing the trail name.

enterAlwaysScrollBehavior(canScroll = { false }) (or omitting scrollBehavior)

Effectively pins the bar while still participating in the nested-scroll chain — useful when a screen wants a fixed bar but still needs to consume/react to scroll deltas for another reason.

Bottom app bar. BottomAppBar occupies Scaffold’s `bottomBar slot and, like TopAppBar, can carry its own actions and an optional docked floatingActionButton cutout:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
Scaffold(
    bottomBar = {
        BottomAppBar(
            actions = {
                IconButton(onClick = onFilterClick) {
                    Icon(Icons.Filled.FilterList, contentDescription = "Filter trails")
                }
                IconButton(onClick = onSortClick) {
                    Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort trails")
                }
            },
            floatingActionButton = {
                FloatingActionButton(onClick = onAddTrailClick, elevation = BottomAppBarDefaults.bottomAppBarFabElevation()) {
                    Icon(Icons.Filled.Add, contentDescription = "Record a new trail")
                }
            },
        )
    },
) { innerPadding -> /* ... */ }

TrailMate reserves BottomAppBar for TrailListScreen’s filter/sort actions and uses `NavigationSuiteScaffold — which shows a NavigationBar on phones (Navigation components: NavigationBar and NavigationRail) — for the app’s top-level destinations, rather than combining both bottom bars on the same screen. SnackbarHost paired with a remembered SnackbarHostState is Scaffold’s slot for transient messages — calling `snackbarHostState.showSnackbar("Trail saved") from a coroutine (typically inside a LaunchedEffect reacting to a one-shot event from the ViewModel, or from rememberCoroutineScope()) is the Compose replacement for the Views stack’s Snackbar.make(view, …​).show(). See Android Developers — Scaffold and Android Developers — Material components in Compose.

Core Components

Material 3’s component library covers the controls almost every screen needs; TrailMate combines the ones below across TrailListScreen, TrailDetailScreen, SettingsScreen and PhotoCaptureScreen.

Buttons. Five button variants share the same parameter shape (onClick, enabled, content) and differ only in emphasis: Button (filled, highest emphasis), FilledTonalButton, ElevatedButton, OutlinedButton and TextButton (lowest emphasis). TrailMate uses Button for "Start hike", OutlinedButton for "Cancel", and TextButton inside dialogs:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
Row {
    OutlinedButton(onClick = onCancelClick) { Text("Cancel") }
    Spacer(Modifier.width(8.dp))
    Button(onClick = onStartHikeClick, enabled = uiState.canStartHike) { Text("Start hike") }
}

Cards. Card, ElevatedCard and OutlinedCard group related content behind a MaterialTheme.shapes-shaped container; TrailMate’s TrailListItem is a clickable Card (the onClick overload) wrapping a thumbnail and two lines of text:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListItem.kt
@Composable
fun TrailListItem(trail: Trail, onClick: () -> Unit, onFavoriteClick: () -> Unit) {
    Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
        Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
            AsyncImage(model = trail.thumbnailUrl, contentDescription = null, modifier = Modifier.size(56.dp))
            Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
                Text(trail.name, style = MaterialTheme.typography.titleMedium)
                Text("${trail.lengthKm} km", style = MaterialTheme.typography.bodyMedium)
            }
            IconToggleButton(checked = trail.isFavorite, onCheckedChange = { onFavoriteClick() }) {
                Icon(
                    if (trail.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                    contentDescription = "Toggle favorite",
                )
            }
        }
    }
}

Chips. AssistChip, FilterChip, InputChip and SuggestionChip cover distinct interaction patterns; TrailListScreen’s difficulty filter uses `FilterChip, whose selected boolean drives its own visual state:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailFilterRow.kt
FilterChip(
    selected = uiState.selectedDifficulty == Difficulty.EASY,
    onClick = { onDifficultySelected(Difficulty.EASY) },
    label = { Text("Easy") },
)

Dialogs. AlertDialog takes onDismissRequest, a confirmButton/dismissButton pair of TextButton`s, and `title/text content slots; TrailMate confirms a destructive action — deleting a saved trail — with one:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
if (uiState.showDeleteConfirmation) {
    AlertDialog(
        onDismissRequest = onDismissDeleteConfirmation,
        title = { Text("Delete this trail?") },
        text = { Text("This cannot be undone.") },
        confirmButton = { TextButton(onClick = onConfirmDelete) { Text("Delete") } },
        dismissButton = { TextButton(onClick = onDismissDeleteConfirmation) { Text("Cancel") } },
    )
}

ModalBottomSheet. A ModalBottomSheet slides up over the current screen, dimming it, and is driven by a rememberModalBottomSheetState(); TrailMate uses one for a trail’s share/export options instead of a full dialog, because the option list can grow past a comfortable dialog height:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailShareSheet.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailShareSheet(onDismiss: () -> Unit, onExportGpx: () -> Unit, onShareImage: () -> Unit) {
    val sheetState = rememberModalBottomSheetState()

    ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
        Column(modifier = Modifier.padding(bottom = 32.dp)) {
            ListItem(
                headlineContent = { Text("Export as GPX") },
                leadingContent = { Icon(Icons.Filled.Route, contentDescription = null) },
                modifier = Modifier.clickable(onClick = onExportGpx),
            )
            ListItem(
                headlineContent = { Text("Share as image") },
                leadingContent = { Icon(Icons.Filled.Share, contentDescription = null) },
                modifier = Modifier.clickable(onClick = onShareImage),
            )
        }
    }
}

Navigation components: NavigationBar and NavigationRail. NavigationBar renders Material 3’s bottom navigation across a fixed set of NavigationBarItem`s; `NavigationRail is its landscape/large-screen counterpart, docked to the leading edge instead of the bottom. Choosing between the two by window size class by hand looks like this:

// app/src/main/kotlin/com/example/trailmate/TrailMateNavigationBar.kt (manual version, for illustration)
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND

val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
    NavigationBar {
        topLevelDestinations.forEach { destination ->
            NavigationBarItem(
                selected = destination == currentDestination,
                onClick = { onNavigate(destination) },
                icon = { Icon(destination.icon, contentDescription = destination.label) },
                label = { Text(destination.label) },
            )
        }
    }
} else {
    NavigationRail {
        topLevelDestinations.forEach { destination ->
            NavigationRailItem(
                selected = destination == currentDestination,
                onClick = { onNavigate(destination) },
                icon = { Icon(destination.icon, contentDescription = destination.label) },
                label = { Text(destination.label) },
            )
        }
    }
}

TrailMate does not ship that manual switch. It uses NavigationSuiteScaffold from androidx.compose.material3:material3-adaptive-navigation-suite, which makes exactly this decision for it — NavigationBar on compact widths, NavigationRail on wider windows (a navigation drawer is available through its layoutType parameter) — from the same currentWindowAdaptiveInfo(), as shown on Navigation in Compose (Navigation Compose and Navigation 3):

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold

NavigationSuiteScaffold(
    navigationSuiteItems = {
        topLevelDestinations.forEach { destination ->
            item(
                selected = destination == currentDestination,
                onClick = { onNavigate(destination) },
                icon = { Icon(destination.icon, contentDescription = destination.label) },
                label = { Text(destination.label) },
            )
        }
    },
) {
    TrailMateNavContent() // the NavHost or NavDisplay
}

TextField. TextField (filled) and OutlinedTextField share the same parameter shape — value/onValueChange, label, supportingText, isError, keyboardOptions — and differ only in visual style; TrailMate’s custom-hike form uses OutlinedTextField with input validation surfaced through isError/supportingText:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/CustomHikeForm.kt
OutlinedTextField(
    value = uiState.lengthKmInput,
    onValueChange = onLengthKmChange,
    label = { Text("Length (km)") },
    isError = uiState.lengthKmError != null,
    supportingText = { uiState.lengthKmError?.let { Text(it) } },
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
    singleLine = true,
)

Switch and Slider. Switch(checked, onCheckedChange) is TrailMate’s SettingsScreen control for dynamicColorEnabled and similar booleans; Slider(value, onValueChange, valueRange) drives a continuous value such as the minimum trail difficulty shown in the list filter:

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/SettingsScreen.kt
Row(verticalAlignment = Alignment.CenterVertically) {
    Text("Use wallpaper colors", modifier = Modifier.weight(1f))
    Switch(checked = uiState.dynamicColorEnabled, onCheckedChange = onDynamicColorToggle)
}
Slider(
    value = uiState.minDifficulty,
    onValueChange = onMinDifficultyChange,
    valueRange = 0f..5f,
)

SearchBar. Material 3’s SearchBar combines a text field with an expandable results surface; TrailMate’s trail search expands over TrailListScreen rather than navigating to a separate screen:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailSearchBar.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrailSearchBar(uiState: TrailSearchUiState, onQueryChange: (String) -> Unit, onResultClick: (String) -> Unit) {
    // Expanded state is UI-only, so it lives here; rememberSaveable keeps it across rotation.
    var expanded by rememberSaveable { mutableStateOf(false) }
    SearchBar(
        inputField = {
            SearchBarDefaults.InputField(
                query = uiState.query,
                onQueryChange = onQueryChange,
                onSearch = {},
                expanded = expanded,
                onExpandedChange = { expanded = it },
                placeholder = { Text("Search trails") },
            )
        },
        expanded = expanded,
        onExpandedChange = { expanded = it },
    ) {
        uiState.results.forEach { result ->
            ListItem(
                headlineContent = { Text(result.name) },
                modifier = Modifier.clickable {
                    expanded = false // collapse before navigating to the result
                    onResultClick(result.id)
                },
            )
        }
    }
}

DatePicker, briefly. DatePickerDialog wrapping a DatePicker (state from rememberDatePickerState()) is Material 3’s calendar-grid date picker; TrailMate uses it once, for filtering the trail list by a "hiked since" date, and otherwise favors SettingsScreen controls that do not need a full calendar surface. See Android Developers — Material components in Compose and Android Developers — androidx.compose.material3 reference for the complete component catalog, including Snackbar, ProgressIndicator, Badge, Tooltip and SegmentedButton, not detailed individually above.

Material 3 Expressive and MotionScheme

Material 3 Expressive is Google’s newer, more animated and more colorful evolution of Material 3 — richer shape morphing, a wider color palette, and a motion system built around springs rather than fixed-duration easing curves. In compose-material3 1.4.0 (this section’s baseline), its APIs — MaterialExpressiveTheme, expressive component variants, and MotionScheme — remain gated behind @ExperimentalMaterial3ExpressiveApi and are not recommended for TrailMate’s production screens yet; the release notes for compose-material3 1.5.0's alpha series describe these APIs graduating to stable there. MotionScheme is the piece worth knowing ahead of that graduation: it centralizes an app’s spring-based motion tokens (defaultSpatialSpec, fastSpatialSpec, defaultEffectsSpec) the same way ColorScheme centralizes colors, so that once it stabilizes, swapping TrailMate’s whole app between a calmer and a more expressive motion feel becomes a one-line change to TrailMateTheme rather than a search-and-replace across every animateFloatAsState/AnimatedVisibility call (Animation and Gestures in Compose covers those APIs on their own). Until then, TrailMate tracks the stable compose-material3 release and revisits Expressive once its APIs are no longer experimental. See Android Developers — androidx.compose.material3 release notes.

Custom Design Systems

Building a design system that departs from Material 3’s roles and components entirely — a distinct ColorScheme-shaped API of an app’s own, custom component composables instead of Button/Card — is possible but rarely worth it for an app the size of TrailMate: it means re-implementing accessibility behavior (touch target sizes, contrast, TalkBack semantics) that material3 already gets right, and losing automatic updates whenever Material 3 itself improves. Cheaper, well-supported alternatives, in the order TrailMate would reach for them, are: overriding MaterialTheme’s `colorScheme/typography/shapes (MaterialTheme: Color Scheme, Typography and Shapes) to reach a distinct brand look while keeping every existing component; wrapping a Material 3 component behind an app-specific composable name (TrailPrimaryButton calling Button internally) so the wrapping call site can add TrailMate-specific defaults without touching the component’s own implementation; and, only for a component Material 3 genuinely does not offer (a route elevation-profile chart, in TrailMate’s case), writing a bespoke composable from Compose’s foundation layer while still reading colors and text styles from MaterialTheme. A true custom design system remains the right call for a large multi-brand product or a company-wide component library, not for a single consumer app. See Android Developers — Building a custom design system in Compose.

Migrating an XML Theme to Compose

A View-based screen theming color, typography and shapes through res/values/themes.xml and MDC’s Theme.Material3. parent themes (Views, XML Layouts, View Binding and Material Components) maps onto TrailMateTheme role by role: an XML colorPrimary attribute becomes ColorScheme.primary, a TextAppearance.Material3.TitleLarge style becomes Typography.titleLarge, and a shapeAppearanceCornerExtraLarge becomes Shapes.extraLarge. TrailMate’s own migration — introducing TrailMateTheme to coexist with the still-live Theme.Material3.DayNight XML theme during the transition, and wrapping remaining XML screens in a ComposeView themed to match — is covered end to end, with before/after code for that step and every step after it, on Migrating from Views to Compose; this page only maps the theme *attributes themselves, not the wider phased plan. See Android Developers — Migrate an XML theme to Compose and Android Developers — Migrate views to Compose.

Icons

androidx.compose.material:material-icons-core ships Icons.Filled/Icons.Outlined/Icons.Rounded/ Icons.Sharp/Icons.TwoTone for the common actions (Add, Share, Favorite); the much larger long-tail set lives in the separate material-icons-extended artifact (Icons.Filled.Route, Icons.Filled.FilterList used above), which is worth excluding from a release build with R8’s resource shrinking if only a handful of its icons are actually referenced, since the full extended set is large. A mirrored-for-RTL icon such as Sort — one whose visual direction should flip in a right-to-left layout — lives instead under Icons.AutoMirrored.Filled.Sort, as used in the BottomAppBar example in Scaffold above; reaching for the plain, non-AutoMirrored variant of an icon that has an AutoMirrored counterpart is a common mistake that silently breaks RTL layouts. Icon(imageVector, contentDescription) is the composable that actually draws one, tinted by LocalContentColor (typically MaterialTheme.colorScheme.onSurface or a component’s own foreground color) unless a tint is passed explicitly; a purely decorative icon that repeats an adjacent text label passes contentDescription = null so screen readers skip it, exactly as `TrailListItem’s icons above only describe themselves where they carry meaning on their own. See Android Developers — Material icons in Compose.