plugins/android-skills/skills/android-ux/SKILL.md
Use when designing or reviewing Android UI — applies Material Design 3 UX principles covering touch targets, spacing, navigation patterns, accessibility, animation timing, and platform conventions. Includes an M3 compliance audit that scores screens across 10 categories. Complements the compose skill with design-level decisions.
npx skillsauth add rcosteira79/android-skills android-uxInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Material Design 3 and Android platform UX principles for building apps that feel native, accessible, and responsive.
Modifier.minimumInteractiveComponentSize() or extra padding// Visual icon is 24dp but touch target is 48dp
IconButton(onClick = onClose) { // IconButton enforces 48dp by default
Icon(Icons.Default.Close, contentDescription = "Close")
}
// Custom component: expand tap area explicitly
Box(
modifier = Modifier
.size(48.dp) // Touch target
.wrapContentSize(Alignment.Center)
) {
Icon(modifier = Modifier.size(24.dp), ...)
}
Use haptics to confirm significant actions (destructive operations, long press, toggle):
val haptic = LocalHapticFeedback.current
Button(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDelete()
}) { Text("Delete") }
NavigationSuiteScaffold to adapt automatically across screen sizes// Correct: both icon and label
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = null) },
label = { Text("Home") },
selected = currentDestination?.hasRoute<Home>() == true,
onClick = { navController.navigate(Home) }
)
// Wrong: icon only (breaks accessibility and usability)
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
selected = ...,
onClick = ...
)
android:enableOnBackInvokedCallback="true" in the manifest and use BackHandler in ComposeKeep primary content and touch targets clear of:
Scaffold(
modifier = Modifier.fillMaxSize()
) { innerPadding ->
// innerPadding already accounts for system bars — always consume it
LazyColumn(contentPadding = innerPadding) { ... }
}
Never hardcode top/bottom padding to approximate system bar heights — use WindowInsets APIs.
Scale navigation and content layout based on window size class:
| Window width | Navigation | Layout | |---|---|---| | Compact (<600dp) | Bottom Bar | Single pane | | Medium (600–840dp) | Navigation Rail | Two pane optional | | Expanded (>840dp) | Navigation Drawer | Two pane |
M3 defines three canonical layout patterns. Start from one of these rather than building from a raw grid:
Foldable devices introduce postures beyond standard window size classes. Use WindowInfoTracker and FoldingFeature from Jetpack WindowManager to detect them:
| Posture | Description | Layout behavior | |---|---|---| | Flat (unfolded) | Device fully open | Treat as Medium or Expanded based on width | | Half-opened (tabletop) | Horizontal fold, bottom half on surface | Content on top half, controls on bottom half | | Half-opened (book) | Vertical fold, held like a book | List on left half, detail on right half | | Folded | Device closed, cover screen | Treat as Compact |
Critical rule: Never place interactive content or critical information across the hinge area.
@Composable
fun FoldAwareLayout() {
val context = LocalContext.current
val layoutInfo by WindowInfoTracker.getOrCreate(context)
.windowLayoutInfo(context) // accepts @UiContext — Activity, InputMethodService, or createWindowContext()
.collectAsStateWithLifecycle(initialValue = WindowLayoutInfo(emptyList()))
val foldingFeature = layoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>()
.firstOrNull()
when {
foldingFeature?.state == FoldingFeature.State.HALF_OPENED -> {
if (foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL) {
TabletopLayout() // top: content, bottom: controls
} else {
BookLayout() // left: list, right: detail
}
}
else -> {
// Standard adaptive layout based on window size class
StandardAdaptiveLayout()
}
}
}
Every Icon and Image that carries meaning needs a contentDescription. Purely decorative elements pass null.
// Meaningful icon
Icon(Icons.Default.Favorite, contentDescription = "Add to favourites")
// Decorative — screen reader skips it
Icon(Icons.Default.Circle, contentDescription = null)
Use Modifier.semantics to express roles, states, and actions that aren't obvious from the visual structure:
Box(
modifier = Modifier.semantics {
role = Role.Button
stateDescription = if (isExpanded) "Expanded" else "Collapsed"
onClick(label = "Toggle section") { onToggle(); true }
}
)
Use mergeDescendants = true to announce a composite item (e.g. a row with an icon and text) as a single unit instead of separate elements:
Row(
modifier = Modifier.semantics(mergeDescendants = true) {}
) {
Icon(Icons.Default.Star, contentDescription = null) // null — merged into parent
Text("4.8 rating")
}
// TalkBack announces: "4.8 rating"
Without mergeDescendants, TalkBack would announce the icon and text as two separate focusable items.
Mark section titles with heading() so screen reader users can jump between sections:
Text(
text = "Recent Orders",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() }
)
Material Design 3 supports three contrast levels that adjust the tonal distance between paired color roles (e.g., primary and onPrimary):
| Level | Value | Effect | |---|---|---| | Standard | 0.0 | Default tonal distance | | Medium | 0.5 | Increased tonal distance, easier to read | | High | 1.0 | Maximum tonal distance, highest legibility |
Compose's dynamicLightColorScheme/dynamicDarkColorScheme do not expose a contrast parameter — they read the system's current setting automatically (SDK 34+). To offer in-app contrast control, use Hct and SchemeContent from MDC-Android:
// These classes live in com.google.android.material:material (MDC-Android)
// Package: com.google.android.material.color.utilities
// Note: marked @RestrictTo(LIBRARY_GROUP) — internal API, may change between versions
val hct = Hct.fromInt(0xFF6750A4.toInt())
val scheme = SchemeContent(hct, /* isDark = */ false, /* contrastLevel = */ 1.0)
val colorScheme = lightColorScheme(
primary = Color(scheme.primary),
onPrimary = Color(scheme.onPrimary),
// ... map remaining roles from scheme
)
Since these are internal MDC APIs, check for updates when bumping Material library versions. Third-party KMP alternatives exist (e.g., com.materialkolor:material-color-utilities) if you need multiplatform support or a stable public API.
Respect the user's system font size — never clamp fontSize to a fixed value in a way that overrides scaling. Use sp units (not dp) for text so the system can scale it.
| Type | Duration | Easing |
|---|---|---|
| Micro-interactions (button press, toggle) | 100–150ms | FastOutSlowIn |
| Standard transitions (screen enter/exit) | 200–300ms | FastOutSlowIn / EmphasizedDecelerate |
| Complex choreography (shared elements) | 300–500ms | Emphasized |
Material 3 ships a 16-step duration ladder. When pairing motion duration with the easing rule above, prefer tokens over arbitrary millisecond values — they survive theme changes and stay consistent across components.
| Token group | Range | Typical use |
|---|---|---|
| short1 … short4 | 50–200ms | Micro-interactions, state changes (ripples, selection, switches) |
| medium1 … medium4 | 250–400ms | Standard transitions (screen enter/exit, expansion, reveal) |
| long1 … long4 | 450–600ms | Container transforms, fade-through between large surfaces |
| extraLong1 … extraLong4 | 700–1000ms | Shared-element choreography, hero transitions on tablets/foldables |
The token tier maps directly to the easing rule: short* with FastOutSlowIn, medium* with EmphasizedDecelerate/EmphasizedAccelerate, long*/extraLong* with Emphasized. Reach for MotionScheme (Compose Material 3 1.4+) where available; otherwise hold the duration value at a single source of truth in your theme rather than per-call-site.
Rules:
LocalReducedMotion CompositionLocal, so create one or check the system setting:// Option 1: Check system setting via CompositionLocal (create once, provide from theme)
val LocalReducedMotion = staticCompositionLocalOf {
false // default: animations enabled
}
// In your theme, read the system setting:
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val reduceMotion = remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f
) == 0f
}
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
MaterialTheme(...) { content() }
}
}
// Then use it in composables:
val reducedMotion = LocalReducedMotion.current
AnimatedVisibility(
visible = isVisible,
enter = if (reducedMotion) EnterTransition.None else fadeIn() + slideInVertically()
) {
Content()
}
Design note:
staticCompositionLocalOfand keylessremembermean the value is read once per Activity lifecycle. If the user toggles "Remove animations" in system settings and returns to the app, the stale value persists until the Activity is recreated. This is intentional — live-updating this setting is overkill for most apps. If you do need live updates, switch tocompositionLocalOfand observeANIMATOR_DURATION_SCALEvia aContentObserver.
KeyboardOptions to trigger the correct keyboard:TextField(
value = email,
onValueChange = { email = it },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)
| Input type | keyboardType |
|---|---|
| Email | KeyboardType.Email |
| Phone | KeyboardType.Phone |
| Number (integer) | KeyboardType.Number |
| Password | KeyboardType.Password |
| URL | KeyboardType.Uri |
Modifier.semantics { contentType = ContentType.EmailAddress }Use this when reviewing a screen or feature for Material Design 3 compliance. Score each category as Pass, Partial, or Fail, then address any Partial/Fail items before shipping.
MaterialTheme.colorScheme roles — no hardcoded hex/ARGB valuesprimary for key actions, secondary for less prominent elements, tertiary for accents, error for error statessurface, surfaceVariant, surfaceContainerLow/High used for layered surfaces — not arbitrary grayson* colors paired correctly (e.g. text on primary uses onPrimary)outline for interactive boundaries needing 3:1 contrast (text field borders, focus rings), outline-variant for decorative dividersdynamicLightColorScheme / dynamicDarkColorScheme) with a static fallbackMaterialTheme.typography — no inline fontSize/fontWeight overridesdisplay* for hero text, headline* for section headers, title* for card/dialog titles, body* for content, label* for buttons and captionsMaterialTheme.shapes — not hardcoded RoundedCornerShape valuesextraSmall (4dp) for chips/small elements, small (8dp) for cards, medium (12dp) for dialogs, large (16dp) for sheets, extraLarge (28dp) for FABsElevatedCard, ElevatedButton used instead of manual shadowElevation on generic surfacessurfaceContainerLowest < surfaceContainerLow < surfaceContainer < surfaceContainerHigh < surfaceContainerHighest. If two adjacent layers render at the same color in either theme, the elevation cue is brokenandroidx.compose.material3.*), not Material 2 (androidx.compose.material.*)FloatingActionButton for the primary screen action, Card for grouped content, TopAppBar for screen-level actions — not repurposed for unrelated patternsThree one-liners that surface the most common M3 violations across an Android module. Run from the project root:
# 1. Hardcoded color literals in Compose — should be MaterialTheme.colorScheme.* roles
rg --type kt 'Color\(0x[0-9a-fA-F]{6,8}\)' --files-with-matches | head
# 2. Hardcoded corner radii — should reference MaterialTheme.shapes.*
rg --type kt 'RoundedCornerShape\(\s*\d+(?:\.\d+)?\s*\.dp\s*\)' --files-with-matches | head
# 3. Material 2 import contamination — should be androidx.compose.material3.*
rg --type kt 'import androidx\.compose\.material\.' --files-with-matches | head
The grep output gives the audit a concrete starting list — each hit is a category-1, category-3, or category-5 violation respectively, before any human review begins.
selected state with active indicatorEmphasized easing at 300–500mscontentDescriptionsemantics { traversalIndex } where needed)semantics { heading() }MaterialTheme wrapping the app — no nested or conflicting themesCompositionLocal, not global objectsColorScheme, not by overriding individual component colorsScreen: [name]
Date: [date]
| # | Category | Score | Notes |
|---|-----------------------|---------|------------------------|
| 1 | Color tokens | Pass | |
| 2 | Typography | Partial | bodySmall hardcoded |
| 3 | Shape | Pass | |
| 4 | Elevation & surface | Pass | |
| 5 | Components | Fail | M2 Scaffold still used |
| 6 | Layout & spacing | Partial | No tablet breakpoint |
| 7 | Navigation | Pass | |
| 8 | Motion | Pass | |
| 9 | Accessibility | Partial | Missing headings |
| 10| Theming consistency | Pass | |
Action items:
- [ ] ...
Before shipping any screen, verify:
Scaffold / WindowInsets (no hardcoded padding)contentDescription or null where decorativesp unitstesting
Use when implementing paginated lists in Android or Compose with Paging 3 — PagingSource, Pager and PagingConfig setup, RemoteMediator for offline-first lists, LazyPagingItems and itemKey integration in LazyColumn, dynamic filters via flatMapLatest, and unit tests with TestPager and asSnapshot. Triggers include Paging 3, infinite list, infinite scroll, paginated list, LazyPagingItems, collectAsLazyPagingItems, and cachedIn.
development
Use when setting up or working with Koin in Android or KMP projects — module declarations with Classic DSL or KSP annotations, ViewModel injection in Compose, scopes, Nav 3 entry providers, application startup, and compile-time verification via `verify()`. Triggers on Koin, `single`, `factory`, `koinViewModel`, `koinInject`, `parametersOf`, `startKoin`, "KMP DI", "shared DI".
development
Use when persisting key-value preferences or small typed settings on Android or KMP with Jetpack DataStore — Preferences vs Typed (Proto/JSON) selection, KMP factory with per-platform file paths, SharedPreferences migration, serializers with corruption handlers, DI singletons, and repository/MVI integration. Triggers on DataStore, Preferences, PreferenceDataStoreFactory, DataStoreFactory, preferencesDataStore, SharedPreferencesMigration, Serializer, or persistent settings work.
development
Use when writing, fixing, or refactoring Android/KMP code in Kotlin — supplements superpowers:test-driven-development with Android's three-tier test model, fake-first strategy, coroutine testing, and Compose UI testing.