Material Design 3 and Android platform UI guidelines. Use when building Android apps with Jetpack Compose or XML layouts, implementing Material You, navigation, or accessibility. Triggers on tasks involving Android UI, Compose components, dynamic color, or Material Design compliance.
Enable dynamic color derived from the user's wallpaper. Dynamic color is the default on Android 12+ and should be the primary theming strategy.
// Compose: Dynamic color theme
@Composable
fun AppTheme(
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 = AppTypography,
content = content
)
}
<!-- XML: Dynamic color in themes.xml -->
<style name="Theme.App" parent="Theme.Material3.DayNight.NoActionBar">
<item name="dynamicColorThemeOverlay">@style/ThemeOverlay.Material3.DynamicColors.DayNight</item>
</style>
Rules:
Material 3 defines a structured set of color roles. Use them semantically, not aesthetically.
| Role | Usage | On-Role |
|------|-------|---------|
| primary | Key actions, active states, FAB | onPrimary |
| primaryContainer | Less prominent primary elements | onPrimaryContainer |
| secondary | Supporting UI, filter chips | onSecondary |
| secondaryContainer | Navigation bar active indicator | onSecondaryContainer |
| tertiary | Accent, contrast, complementary | onTertiary |
| tertiaryContainer | Input fields, less prominent accents | onTertiaryContainer |
| surface | Backgrounds, cards, sheets | onSurface |
| surfaceVariant | Decorative elements, dividers | onSurfaceVariant |
| error | Error states, destructive actions | onError |
| errorContainer | Error backgrounds | onErrorContainer |
| outline | Borders, dividers | — |
| outlineVariant | Subtle borders | — |
| inverseSurface | Snackbar background | inverseOnSurface |
// Correct: semantic color roles
Text(
text = "Error message",
color = MaterialTheme.colorScheme.error
)
Surface(color = MaterialTheme.colorScheme.errorContainer) {
Text(text = "Error detail", color = MaterialTheme.colorScheme.onErrorContainer)
}
// WRONG: hardcoded colors
Text(text = "Error", color = Color(0xFFB00020)) // Anti-pattern
Rules:
on color role for its background (e.g., onPrimary text on primary background).surface and its variants for backgrounds. Never use primary or secondary as large background areas.tertiary sparingly for accent and complementary contrast only.Support both light and dark themes. Respect the system setting by default.
// Compose: Detect system theme
val darkTheme = isSystemInDarkTheme()
Rules:
surface color roles which handle this automatically.When branding requires custom colors, provide a seed color and generate tonal palettes using Material Theme Builder.
// Custom color scheme with brand seed
private val BrandLightColorScheme = lightColorScheme(
primary = Color(0xFF1B6D2F),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFA4F6A8),
onPrimaryContainer = Color(0xFF002107),
// ... generate full palette from seed
)
Rules:
The primary navigation pattern for phones with 3-5 top-level destinations.
// Compose: Navigation Bar
NavigationBar {
items.forEachIndexed { index, item ->
NavigationBarItem(
icon = {
Icon(
imageVector = if (selectedItem == index) item.filledIcon else item.outlinedIcon,
contentDescription = item.label
)
},
label = { Text(item.label) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
Rules:
secondaryContainer color. Do not override this.For medium and expanded screens (tablets, foldables, desktop).
// Compose: Navigation Rail for larger screens
NavigationRail(
header = {
FloatingActionButton(
onClick = { /* primary action */ },
containerColor = MaterialTheme.colorScheme.tertiaryContainer
) {
Icon(Icons.Default.Add, contentDescription = "Create")
}
}
) {
items.forEachIndexed { index, item ->
NavigationRailItem(
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
Rules:
For 5+ destinations or complex navigation hierarchies, typically on expanded screens.
// Compose: Permanent Navigation Drawer for large screens
PermanentNavigationDrawer(
drawerContent = {
PermanentDrawerSheet {
Text("App Name", modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium)
HorizontalDivider()
items.forEach { item ->
NavigationDrawerItem(
label = { Text(item.label) },
selected = item == selectedItem,
onClick = { selectedItem = item },
icon = { Icon(item.icon, contentDescription = null) }
)
}
}
}
) {
Scaffold { /* page content */ }
}
Rules:
Android 13+ supports predictive back with an animation preview.
// Compose: Predictive back with BackHandler (androidx.activity.compose)
BackHandler(enabled = true) {
// Called when back is confirmed; navigate back in your nav controller
navController.popBackStack()
}
// Compose: Predictive back progress animation using predictiveBackHandler modifier
// (androidx.activity:activity-compose 1.8+)
Modifier.predictiveBackHandler(enabled = true) { progress ->
// progress is a Flow<BackEventCompat> with x, y, swipeEdge, progress (0.0–1.0)
progress.collect { backEvent ->
animationState = backEvent.progress
}
}
<!-- AndroidManifest.xml: opt in to predictive back -->
<application android:enableOnBackInvokedCallback="true">
Rules:
BackHandler (from androidx.activity.compose) to intercept back events. In View-based apps, implement OnBackInvokedCallback (API 33+) or OnBackPressedCallback (AndroidX) instead of overriding onBackPressed().BackEventCompat.progress (0.0–1.0) and respect BackEventCompat.swipeEdge (EDGE_LEFT/EDGE_RIGHT) so the exiting screen scales down and shifts toward the initiating edge, matching the system animation.// Compose: drive a custom animation from predictive back progress
Modifier.predictiveBackHandler(enabled = true) { progress ->
progress.collect { backEvent ->
// backEvent.progress: 0.0 (gesture start) → 1.0 (committed)
// backEvent.swipeEdge: BackEventCompat.EDGE_LEFT or EDGE_RIGHT
exitScale = 1f - (backEvent.progress * 0.1f)
exitOffsetX = if (backEvent.swipeEdge == BackEventCompat.EDGE_LEFT) -backEvent.progress * 32.dp.toPx() else backEvent.progress * 32.dp.toPx()
}
}
| Screen Size | 3-5 Destinations | 5+ Destinations | |-------------|-------------------|-----------------| | Compact (< 600dp) | Navigation Bar | Modal Drawer + Navigation Bar | | Medium (600-839dp) | Navigation Rail | Modal Drawer + Navigation Rail | | Expanded (840dp+) | Navigation Rail | Permanent Drawer |
Use window size classes for adaptive layouts, not raw pixel breakpoints.
// Compose: Window size classes
val windowSizeClass = calculateWindowSizeClass(this)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> CompactLayout()
WindowWidthSizeClass.Medium -> MediumLayout()
WindowWidthSizeClass.Expanded -> ExpandedLayout()
}
| Class | Width | Typical Device | Columns | |-------|-------|----------------|---------| | Compact | < 600dp | Phone portrait | 4 | | Medium | 600-839dp | Tablet portrait, foldable | 8 | | Expanded | 840dp+ | Tablet landscape, desktop | 12 |
Rules:
WindowSizeClass from material3-window-size-class for responsive layout decisions.Apply canonical Material grid margins and gutters.
| Size Class | Margins | Gutters | Columns | |------------|---------|---------|---------| | Compact | 16dp | 8dp | 4 | | Medium | 24dp | 16dp | 8 | | Expanded | 24dp | 24dp | 12 |
Rules:
Android 15+ enforces edge-to-edge. All apps should draw behind system bars.
// Compose: Edge-to-edge setup
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
Scaffold(
modifier = Modifier.fillMaxSize(),
// Scaffold handles insets for top/bottom bars automatically
) { innerPadding ->
Content(modifier = Modifier.padding(innerPadding))
}
}
}
}
Rules:
enableEdgeToEdge() before setContent. Draw behind both status bar and navigation bar.WindowInsets to pad content away from system bars. Scaffold handles this for top bar and bottom bar content automatically.// Compose: Detect fold posture
val foldingFeatures = WindowInfoTracker.getOrCreate(context)
.windowLayoutInfo(context)
.collectAsState(initial = WindowLayoutInfo(emptyList()))
Rules:
ListDetailPaneScaffold or SupportingPaneScaffold from Material3 adaptive library for foldable-aware layouts.| Role | Default Size | Default Weight | Usage | |------|-------------|----------------|-------| | displayLarge | 57sp | 400 | Hero text, onboarding | | displayMedium | 45sp | 400 | Large feature text | | displaySmall | 36sp | 400 | Prominent display | | headlineLarge | 32sp | 400 | Screen titles | | headlineMedium | 28sp | 400 | Section headers | | headlineSmall | 24sp | 400 | Card titles | | titleLarge | 22sp | 400 | Top app bar title | | titleMedium | 16sp | 500 | Tabs, navigation | | titleSmall | 14sp | 500 | Subtitles | | bodyLarge | 16sp | 400 | Primary body text | | bodyMedium | 14sp | 400 | Secondary body text | | bodySmall | 12sp | 400 | Captions | | labelLarge | 14sp | 500 | Buttons, prominent labels | | labelMedium | 12sp | 500 | Chips, smaller labels | | labelSmall | 11sp | 500 | Timestamps, annotations |
// Compose: Custom typography
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily(Font(R.font.brand_regular)),
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily(Font(R.font.brand_regular)),
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
// ... define all 15 roles
)
Rules:
sp units for text sizes to support user font scaling preferences.MaterialTheme.typography, not hardcoded sizes.The FAB represents the single most important action on a screen.
// Compose: FAB variants
// Standard FAB
FloatingActionButton(onClick = { /* action */ }) {
Icon(Icons.Default.Add, contentDescription = "Create new item")
}
// Extended FAB (with label - preferred for clarity)
ExtendedFloatingActionButton(
onClick = { /* action */ },
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
text = { Text("Compose") }
)
// Large FAB
LargeFloatingActionButton(onClick = { /* action */ }) {
Icon(Icons.Default.Add, contentDescription = "Create", modifier = Modifier.size(36.dp))
}
Rules:
primaryContainer color by default. Use tertiaryContainer for secondary screens.ExtendedFloatingActionButton with a label for clarity. Collapse to icon-only on scroll if needed.// Compose: Top app bar variants
// Small (default)
TopAppBar(
title = { Text("Page Title") },
navigationIcon = {
IconButton(onClick = { /* navigate up */ }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { /* search */ }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
)
// Medium — expands title area
MediumTopAppBar(
title = { Text("Section Title") },
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
)
// Large — for prominent titles
LargeTopAppBar(
title = { Text("Screen Title") },
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
)
Rules:
TopAppBar (small) for most screens. Use MediumTopAppBar or LargeTopAppBar for prominent section or screen titles.// Compose: Modal bottom sheet
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = rememberModalBottomSheetState()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Sheet Title", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
// Sheet content
}
}
Rules:
// Compose: Alert dialog
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text("Discard draft?") },
text = { Text("Your unsaved changes will be lost.") },
confirmButton = {
TextButton(onClick = { /* confirm */ }) { Text("Discard") }
},
dismissButton = {
TextButton(onClick = { showDialog = false }) { Text("Cancel") }
}
)
Rules:
// Compose: Snackbar with action
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) {
// trigger snackbar
LaunchedEffect(key) {
val result = snackbarHostState.showSnackbar(
message = "Item archived",
actionLabel = "Undo",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) { /* undo */ }
}
}
Rules:
// Filter Chip
FilterChip(
selected = isSelected,
onClick = { isSelected = !isSelected },
label = { Text("Filter") },
leadingIcon = if (isSelected) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
// Assist Chip
AssistChip(
onClick = { /* action */ },
label = { Text("Add to calendar") },
leadingIcon = { Icon(Icons.Default.CalendarToday, contentDescription = null) }
)
Rules:
FilterChip for toggling filters, AssistChip for smart suggestions, InputChip for user-entered content (tags), SuggestionChip for dynamically generated suggestions.| Need | Component | |------|-----------| | Primary screen action | FAB | | Brief feedback | Snackbar | | Critical decision | Dialog | | Supplementary content | Bottom Sheet | | Toggle filter | Filter Chip | | User-entered tag | Input Chip | | Smart suggestion | Assist Chip | | Content group | Card | | Vertical list of items | LazyColumn with ListItem | | Segmented option (2-5) | SegmentedButton | | Binary toggle | Switch | | Selection from list | Radio buttons or exposed dropdown menu |
// Compose: Accessible components
Icon(
Icons.Default.Favorite,
contentDescription = "Add to favorites" // Descriptive, not "heart icon"
)
// Decorative elements
Icon(
Icons.Default.Star,
contentDescription = null // null for purely decorative
)
// Merge semantics for compound elements
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Icon(Icons.Default.Event, contentDescription = null)
Text("March 15, 2026")
}
// Custom actions
Box(modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Archive") { /* archive */ true },
CustomAccessibilityAction("Delete") { /* delete */ true }
)
})
Rules:
contentDescription (or null if purely decorative).mergeDescendants = true to group related elements into a single TalkBack focus unit (e.g., a list item with icon + text + subtitle).customActions for swipe-to-dismiss or long-press actions so TalkBack users can access them.// Compose: Ensure minimum touch target
IconButton(onClick = { /* action */ }) {
// IconButton already provides 48dp minimum touch target
Icon(Icons.Default.Close, contentDescription = "Close")
}
// Manual minimum touch target
Box(
modifier = Modifier
.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
.clickable { /* action */ },
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Info, contentDescription = "Info", modifier = Modifier.size(24.dp))
}
Rules:
Rules:
Configuration.fontWeightAdjustment (API 31+) to detect the user's bold text preference and scale custom font weights accordingly. Use AccessibilityManager.isHighTextContrastEnabled() to deSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer