Focus Management and Keyboard Navigation in Compose Forms

Quick answer: Compose handles ordinary focus traversal for you. Add custom focus logic only when the default order is confusing: request a field after a deliberate action, group related controls, or specify a meaningful next target. Test with Tab, Shift+Tab, arrow keys, a hardware keyboard, and TalkBack—not only taps.

Focus is the answer to a simple question: which component receives keyboard or D-pad input right now? It matters on phones with hardware keyboards, tablets, ChromeOS, TV, and accessibility input. A form that works only when tapped can feel broken everywhere else.

The goal is not to manually wire every field. Start with Compose defaults, make the screen’s visual and declaration order agree, then make a focused exception when the user experience demands it.

Know the default traversal behavior

Compose supports two broad navigation styles:

InputTraversal modelWhat to design for
Tab or Shift+TabOne-dimensionalA predictable next/previous sequence based on composable declaration order
Arrow keys or D-padTwo-dimensionalA nearby target in the requested visual direction

Android’s Focus in Compose guide explains that Tab traversal follows composable declaration order by default, while arrow/D-pad navigation uses the spatial arrangement of focus targets. That is usually ideal for a simple vertical form:

@Composable
fun ProfileForm() {
    var name by rememberSaveable { mutableStateOf("") }
    var email by rememberSaveable { mutableStateOf("") }

    Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            modifier = Modifier.fillMaxWidth(),
            label = { Text("Name") },
        )
        OutlinedTextField(
            value = email,
            onValueChange = { email = it },
            modifier = Modifier.fillMaxWidth(),
            label = { Text("Email") },
        )
        Button(onClick = { /* save */ }) {
            Text("Save")
        }
    }
}

Keep the code order aligned with the visual order. Rearranging fields visually while leaving their declarations in a different order can make Tab navigation surprising. This is especially easy to introduce in responsive layouts; use the adaptive layout guidance when a form changes shape across window sizes.

Move focus after a deliberate action

Use FocusRequester when the app has a clear reason to put focus somewhere. A common case is returning a user to the first invalid field after they submit a form.

@Composable
fun EmailStep() {
    val emailRequester = remember { FocusRequester() }
    var email by rememberSaveable { mutableStateOf("") }
    var emailError by rememberSaveable { mutableStateOf<String?>(null) }

    Column {
        OutlinedTextField(
            value = email,
            onValueChange = {
                email = it
                emailError = null
            },
            modifier = Modifier
                .fillMaxWidth()
                .focusRequester(emailRequester),
            label = { Text("Email") },
            isError = emailError != null,
            supportingText = { emailError?.let(::Text) },
        )

        Button(
            onClick = {
                if (email.isBlank()) {
                    emailError = "Enter an email address."
                    emailRequester.requestFocus()
                } else {
                    // Continue.
                }
            },
        ) {
            Text("Continue")
        }
    }
}

The official change-focus-behavior documentation recommends calling requestFocus() in response to an event, outside the composable body. Calling it directly while composing would request focus again on every recomposition.

For a screen driven by a ViewModel, let the ViewModel emit a one-time effect such as FocusEmail, and let the screen collect it and call the requester. Keep the requester itself in the UI layer: it controls a UI object, not business state. Your state-hoisting design can still own the form data and validation result.

Move, clear, and constrain focus carefully

LocalFocusManager lets a focused component advance focus or clear it. It is useful for an intentional keyboard shortcut or a custom composite control, not as a replacement for normal traversal.

@Composable
fun KeyboardAwareField() {
    val focusManager = LocalFocusManager.current
    var query by rememberSaveable { mutableStateOf("") }

    OutlinedTextField(
        value = query,
        onValueChange = { query = it },
        modifier = Modifier
            .fillMaxWidth()
            .onPreviewKeyEvent { event ->
                if (event.type == KeyEventType.KeyUp && event.key == Key.Tab) {
                    focusManager.moveFocus(FocusDirection.Next)
                    true
                } else {
                    false
                }
            },
        label = { Text("Search") },
    )
}

The default Tab behavior normally already advances focus, so do not add this handler just to duplicate it. The official example is valuable when you truly need to intercept a key event and substitute a direction. Return false for keys you do not handle so Compose and the focused control can continue their normal behavior.

To dismiss keyboard focus after a completed action, use focusManager.clearFocus(). Do it when it improves the flow—for example, after submitting a search—not merely because a button was pressed. Clearing focus while users are correcting validation errors makes a form harder to use.

Define a custom order only where it helps

For complex screens, attach requesters to the intended targets and use focusProperties to state the relationship. This example makes a two-column account form move from the left field to the corresponding right field when navigating right:

@Composable
fun TwoColumnForm() {
    val (firstName, lastName, phone, email) = remember {
        FocusRequester.createRefs()
    }

    Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
        Column(Modifier.weight(1f)) {
            OutlinedTextField(
                value = "",
                onValueChange = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(firstName)
                    .focusProperties { right = lastName },
                label = { Text("First name") },
            )
            OutlinedTextField(
                value = "",
                onValueChange = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(phone)
                    .focusProperties { right = email },
                label = { Text("Phone") },
            )
        }
        Column(Modifier.weight(1f)) {
            OutlinedTextField(
                value = "",
                onValueChange = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(lastName),
                label = { Text("Last name") },
            )
            OutlinedTextField(
                value = "",
                onValueChange = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .focusRequester(email),
                label = { Text("Email") },
            )
        }
    }
}

The traversal-order guide shows the same pattern: create stable requesters, associate them with focusable components, then assign next, previous, or directional destinations. Be sparse. An exhaustive hard-coded graph becomes brittle when fields are conditional or the layout adapts.

Modifier order is meaningful here. focusProperties closer to the start of a modifier chain wraps modifiers after it; when multiple focusProperties conflict, the outermost one takes precedence. This follows the same ordering principle explained in the Modifier order guide.

A horizontal row of filters, tabs, or segmented controls can be partially off-screen. Without extra guidance, focus search may skip from a visible child to another nearby target outside the row. Apply focusGroup() to tell Compose to treat the children as a coherent group.

Row(
    modifier = Modifier
        .horizontalScroll(rememberScrollState())
        .focusGroup(),
) {
    FilterChip(selected = true, onClick = {}, label = { Text("All") })
    FilterChip(selected = false, onClick = {}, label = { Text("Unread") })
    FilterChip(selected = false, onClick = {}, label = { Text("Starred") })
}

This does not make the Row itself the target. It helps Compose visit the related focusable children as a unit. Android’s focus behavior guide uses this approach to make navigation through complex scrolling groups more coherent.

Make focus visible and accessible

Material fields and buttons already expose focus behavior, but custom clickable surfaces need a clear cue. Use onFocusChanged to update visual state when necessary, and keep the cue distinct from selection and error states.

@Composable
fun FocusableOption(label: String, onClick: () -> Unit) {
    var focused by remember { mutableStateOf(false) }

    Surface(
        modifier = Modifier
            .onFocusChanged { focused = it.isFocused }
            .focusable()
            .clickable(onClick = onClick),
        border = if (focused) {
            BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
        } else {
            null
        },
    ) {
        Text(label, Modifier.padding(16.dp))
    }
}

Do not rely only on a faint color change. The cue should remain visible with large text, dark mode, and different contrast settings. It should also be consistent with the Material 3 theme rather than inventing a second interaction language.

Keyboard focus and screen-reader traversal are related but not identical. If you build custom semantics or reorder content visually, review the Compose accessibility guidance as well. Preserve a logical reading order and never trap a keyboard user inside a group with no route out.

Test the paths users actually take

Manual testing is essential for focus because geometry changes with a tablet, landscape mode, language direction, and dynamic content.

  1. Use Tab and Shift+Tab to walk through the complete form.
  2. Use arrow keys or a D-pad in both portrait and landscape layouts.
  3. Confirm the initial focused item is the one users expect after an explicit action.
  4. Make an error, submit, and confirm focus moves to the first useful correction target only once.
  5. Test with TalkBack, larger font scales, and a physical keyboard or a ChromeOS/tablet emulator.

Compose tests can assert focused semantics after a triggered action:

composeTestRule.onNodeWithText("Continue").performClick()

composeTestRule
    .onNodeWithText("Email")
    .assertIsFocused()

Use stable semantics, content descriptions, or test tags for controls whose visible label can change. Test behavior—where focus lands and whether the user can proceed—not internal implementation details.

Common mistakes

Requesting focus during composition

This repeats after recomposition and can steal focus while the user types. Request it from a click, a controlled effect, or another explicit event.

Hard-coding every next target

Default traversal is easier to maintain for ordinary vertical forms. Add overrides only at a proven confusing boundary.

Treating focus as only a text-field concern

Buttons, chips, tabs, custom cards, and controls need a logical route too—especially on TV, ChromeOS, and large screens.

Removing a user from the field that needs correction

After validation, focus the first invalid field only after a submit attempt. Pair it with the clear error message described in the form validation guide.

FAQ

Do I need FocusRequester for every TextField?

No. A normal form can rely on Compose’s default focus search. Use requesters for an intentional initial or recovery target, or when you override a specific traversal relationship.

Does focusGroup() make a parent focusable?

No. It groups descendant focus targets for search. Add focusable() only when the component itself should receive focus.

Should I handle Tab myself?

Usually not. Compose already supports Tab traversal. Intercept it only when your UI needs a deliberate behavior that differs from the default, and return false for unrelated keys.

Summary

Compose defaults are the right starting point for focus. Keep declaration and visual order aligned, request focus only in response to a clear event, use groups and traversal rules for genuine complex-layout problems, and verify the whole path with keyboard, D-pad, and accessibility tools.