Help us improve
Share bugs, ideas, or general feedback.
From chrisbanes-skills
Provides guidance on implementing focus navigation in Jetpack Compose for TV, keyboard, desktop, and accessibility. Covers FocusRequester, focusProperties, and testing focus behavior.
npx claudepluginhub chrisbanes/skills --plugin chrisbanes-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/chrisbanes-skills:compose-focus-navigationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Focus is stateful UI behavior. Make focus targets explicit, request focus after composition succeeds, and test navigation with the same input model users use: keyboard, D-pad, or remote keys.
Provides expertise in Jetpack Compose and Compose Multiplatform for UI development across Android, Desktop, iOS, Web. Covers APIs, navigation, Paging 3, Android TV, design systems, and PR reviews.
Guides writing Jetpack Compose UI tests: plain state-driven tests, semantics assertions, callback testing, interaction states, screenshot tests, and keyboard/focus assertions.
Provides mobile accessibility patterns for Android Jetpack Compose and iOS: content descriptions, semantics, touch targets, screen reader support, WCAG compliance, dynamic type, color contrast.
Share bugs, ideas, or general feedback.
Focus is stateful UI behavior. Make focus targets explicit, request focus after composition succeeds, and test navigation with the same input model users use: keyboard, D-pad, or remote keys.
Use this when UI:
FocusRequester, focusRequester, focusProperties, onFocusChanged, or key handlers.Start with components that already participate in focus, then add only the focus hooks the behavior needs:
| Need | Add |
|---|---|
| Normal button/text field/clickable focus | Nothing extra; use the focusable component |
| Programmatic initial/restored focus | FocusRequester + Modifier.focusRequester(...) |
| Visual or state reaction to focus changes | Modifier.onFocusChanged { ... } |
| Custom interactive surface that is not already focusable | Modifier.focusable() plus role/semantics as appropriate |
For example, request and observe focus only when both behaviors are needed:
val requester = remember { FocusRequester() }
Button(
onClick = onClick,
modifier = Modifier
.focusRequester(requester)
.onFocusChanged { state -> isFocused = state.isFocused },
) {
Text("Play")
}
Prefer focusable components (Button, TextField, clickable/selectable surfaces) over manually adding focusable() to passive layout. Add manual focus only when the element is truly interactive or participates in navigation.
Call focus requests from an effect, not from the composable body:
val initialFocus = remember { FocusRequester() }
LaunchedEffect(initialFocus) {
initialFocus.requestFocus()
}
If the target appears after loading, key the request to the condition:
LaunchedEffect(items.isNotEmpty()) {
if (items.isNotEmpty()) {
firstItemRequester.requestFocus()
}
}
For lazy content, request focus only after the item is actually composed. Keep requesters in stable item state keyed by item id, not by index alone if the list can reorder.
Use focusProperties when default spatial search is wrong:
Modifier.focusProperties {
up = headerRequester
down = firstRowRequester
left = FocusRequester.Cancel
}
Use this sparingly. Too many hard-coded links create stale focus graphs when layouts change. Prefer natural focus order unless the design requires a specific jump or trap.
Use key handlers for behavior that is not normal click/focus traversal:
Modifier.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
onBack()
true
} else {
false
}
}
Return true only when consumed. Returning true too broadly breaks text entry, accessibility shortcuts, and parent navigation.
For rapid D-pad input, throttle at the boundary that owns the expensive behavior (for example row scrolling or paging), not globally across the whole screen.
Preserve focus by semantic identity:
key values in lazy lists and grids.| Mistake | Fix |
|---|---|
Adding focusRequester and onFocusChanged to every button | Add them only when requesting or observing focus |
requestFocus() in the composable body | Move to LaunchedEffect |
Initial focus keyed to Unit while target appears later | Key to loaded/visible condition |
| Focus requesters stored by lazy list index | Store by stable item id |
Everything gets custom focusProperties | Let spatial search work; override only broken edges |
Key handler returns true for all keys | Consume only handled keys |
| Tests click nodes in TV/D-pad UI | Send key input and assert focus |
Test focus through user input:
composeTestRule.onNodeWithTag("screen").performKeyInput {
pressKey(Key.DirectionDown)
}
composeTestRule.onNodeWithTag("play-button").assertIsFocused()
Prefer asserting focused semantics over visual styling. Use screenshot tests only for focus appearance, not for deterministic focus ownership.
Broader test-shape choices (plain UI vs integration, semantics-first): compose-ui-testing-patterns.