Focus is the input model you don't test until someone plugs in a keyboard
Compose's focus system handles keyboard, D-pad and accessibility navigation. The APIs are small, but modifier order matters, and a form with no focus handling is unusable on any device without a touchscreen.

Day 76 of 100. Touch is one input model; focus is the other, and it's the one that decides whether your app works on a TV, a Chromebook, a desktop window or with a switch device.
The symptom
A login form that requires reaching for the screen:
Column {
TextField(state = emailState, label = { Text("Email") })
TextField(state = passwordState, label = { Text("Password") })
Button(onClick = ::signIn) { Text("Sign in") }
}
Type the email, press Tab — nothing moves, or focus jumps somewhere unexpected. Press Enter — nothing submits. On a laptop, the user has to touch the trackpad between every field, which is exactly the friction Day 54's autofill was removing.
On a TV with a D-pad, the same form may be entirely unusable.
Why the obvious fix fails
The obvious fix is to make everything focusable:
Column {
TextField(…, modifier = Modifier.focusable())
TextField(…, modifier = Modifier.focusable())
Button(…, modifier = Modifier.focusable())
}
Text fields and buttons are already focusable — clickable includes it, Day 72. Adding
focusable() on top can produce two focus targets for one control, so Tab stops twice
on the same button.
The problem isn't focusability. It's that nothing connects the fields to each other, and nothing tells the IME what its action key should do.

The actual mechanism
Focus moves through a tree that parallels the layout, and three pieces cover almost everything.
FocusRequester — a handle to move focus to a specific element:
val passwordFocus = remember { FocusRequester() }
TextField(
state = emailState,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
onKeyboardAction = { passwordFocus.requestFocus() },
)
TextField(
state = passwordState,
modifier = Modifier.focusRequester(passwordFocus),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onKeyboardAction = { signIn() },
)
Two fields, wired. The IME's action key now advances and then submits — Day 52's
onKeyboardAction, doing the work it exists for.
FocusManager — directional movement without naming a target:
val focusManager = LocalFocusManager.current
onKeyboardAction = { focusManager.moveFocus(FocusDirection.Down) }
Better than a requester per field when the order is just "the next one", because adding a
field doesn't require rewiring its neighbours. focusManager.clearFocus() is the
companion — it dismisses the keyboard, which is what a "Done" action or a tap on the
background should do.
Modifier.focusGroup() — a container whose children are traversed together:
Row(Modifier.focusGroup()) { FilterChips() }
Row(Modifier.focusGroup()) { SortChips() }
Without grouping, Tab in a two-column layout interleaves the columns — the same positional problem as Day 69's traversal, in the focus system rather than the semantics one. They're separate mechanisms with the same failure mode.
The modifier order that breaks it
The one gotcha worth memorising:
Modifier.focusRequester(requester).focusable() // works
Modifier.focusable().focusRequester(requester) // silently does nothing
focusRequester must come before the thing that makes the element focusable. Day 20's
rule — each element wraps the rest — means a requester placed after has nothing to attach
to.
There's no error. requestFocus() just does nothing, which is a genuinely unpleasant
half-hour of debugging the first time.
Same for onFocusChanged, which must precede focusable():
Modifier
.onFocusChanged { state -> hasFocus = state.isFocused }
.focusRequester(requester)
.focusable()
Making focus visible
A focus target with no visual indication is unusable even for someone who can see it:
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
Box(
Modifier
.border(
width = if (focused) 2.dp else 0.dp,
color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = shape,
)
.clickable(interactionSource = interactionSource, indication = ripple()) { … }
)
Material components draw a focus indicator already. Custom controls don't, and a keyboard user tabbing through a screen where nothing visibly changes has no idea where they are.
This is the focus equivalent of Day 72's missing ripple, and it fails the same way: the mechanism works and the feedback is absent.
Initial focus, and when not to take it
LaunchedEffect(Unit) { searchFocus.requestFocus() }
Right for a search screen the user navigated to in order to type. Wrong almost everywhere else — auto-focusing a field on a content screen pops the keyboard over the content the user came to read, and on a TV it steals focus from wherever the user was.
The test: did the user's action imply they want to type? Opening a search screen, yes. Landing on a form after navigation, usually no.
Focus and scrolling
One interaction that's handled for you and worth knowing about: focusing an element inside
a scrollable container scrolls it into view automatically. bringIntoViewRequester is the
manual version for the cases where you need it explicitly — revealing a validation error,
or scrolling to a field the keyboard would otherwise cover.
How to prove it
The whole check is one pass with a keyboard, and it takes a minute:
Tab from the top of the screen to the bottom. Three questions — does focus visibly move, does the order match the reading order, and can every interactive element be reached and activated with Enter or Space?
For automation, focus state is assertable:
@Test fun tabMovesToPassword() = runComposeUiTest {
setContent { LoginForm() }
onNodeWithTag("email").performClick()
onNodeWithTag("email").performImeAction()
onNodeWithTag("password").assertIsFocused()
}
performImeAction fires the keyboard's action key, so this tests the wiring rather than
the appearance.
On a TV or with a D-pad, arrow through the screen. Anything unreachable there is unreachable for switch-access users too — the two navigate the same tree.
What this generalizes to
The principle is there is more than one way to point at something, and a UI built for only one of them excludes everyone using the others. Touch has a position; focus has an order. A control that responds to the first and not the second works for most people and nobody else.
The audiences overlap more than they look: keyboard users on desktop, D-pad users on TV, switch-access users, and anyone whose device's touchscreen is cracked. One focus pass per screen serves all of them, and it's the same minute Day 70 asked for with TalkBack.
Focus traps, and the one place you want one
A dialog should keep focus inside itself — tabbing out of a modal to the content behind it
is disorienting, and on a screen reader it's worse. Dialog and ModalBottomSheet handle
this, which is another reason Day 39 preferred them over a hand-rolled overlay in a Box.
Where you build your own overlay, Modifier.focusProperties { canFocus = false } on the
content behind it is the manual equivalent:
Box {
ScreenContent(Modifier.focusProperties { canFocus = false })
CustomOverlay()
}
The general rule is that a focus trap is correct exactly when the user cannot proceed without dealing with the thing trapping them — a modal — and wrong everywhere else. An accidental trap, usually from a custom key handler that swallows Tab, leaves a keyboard user stuck with no way out but the back gesture.
Tomorrow, Day 77 closes the gestures pillar with drag-and-drop — including across apps.
Day 76 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Focus in Compose.