Skip to content

Focus management

Focus decides which part of your interface receives keyboard input. Fleury’s built-in controls can already take focus, show it, and handle the keys they understand.

Movement between controls is called focus traversal. Fleury provides two forms automatically:

  • Sequential traversal: Tab and Shift+Tab follow visual reading order.
  • Spatial navigation: the arrow keys move through the two-dimensional layout.

Both forms work out of the box in a FleuryApp. You work with focus directly only when you build a custom control, move focus after an action, observe an active region, or construct an overlay. For commands that run after a key reaches the focused region, see Key handling.

You need to…Use
Let users move between ordinary controlsBuilt in with FleuryApp
Make a custom widget focusableFocus
Move focus from codeFocusNode
React when focus enters or leaves a subtreeFocusDetector
Render from whether this Focus is the keyboard targetFocus.of(context).hasFocus
Open a dialog and restore focus afterwardcontext.present

FleuryApp gives every screen and dialog automatic focus traversal:

FleuryApp(
title: 'Workspace',
home: Row(
children: [
Expanded(
child: Column(
children: [
Button(text: 'New file', autofocus: true, onPressed: createFile),
Button(text: 'Open file', onPressed: openFile),
Button(text: 'Settings', onPressed: openSettings),
],
),
),
Expanded(
child: Column(
children: [
Button(text: 'Refresh', onPressed: refresh),
Button(text: 'Inspect', onPressed: inspect),
Button(text: 'Publish…', onPressed: publish),
],
),
),
],
),
)

A focused control always sees the key first. An arrow moves a list selection or text cursor while that control can use it; at the edge, the unhandled arrow can continue outward and move focus. This gives collection widgets useful internal navigation without trapping the user.

Fleury provides this policy with a FocusTraversalGroup at each route root. You normally do not add one yourself; build the layout and let traversal follow it.

Click the example, then try the behavior together:

  • Tab scans each visual row from left to right; Shift+Tab reverses it.
  • Arrow keys move to a control in the requested direction.
  • Enter activates the focused button.
  • Publish… opens a dialog; Tab stays inside, then focus returns to Publish… when the dialog closes.
Try automatic focus traversal
Live · interactive
click & type to interact ⓘ how this demo runs

The highlighted panel only makes the automatic path visible. The screen does not configure focus traversal.

Arrow-driven focus movement is commonly called spatial navigation. Fleury uses the painted layout and the widget tree together. When a focused control leaves an arrow unhandled, Fleury:

  1. Considers visible, focusable controls in the requested direction.
  2. Prefers controls in the same logical region, such as siblings in one pane.
  3. Prefers a focusable child over its focusable container.
  4. Chooses the best-aligned nearby control, with a stable order as the final tie-breaker.

Arrows do not wrap. If there is no target in that direction, the key remains unhandled so an ancestor can respond. Because navigation follows the rendered layout, moving or resizing a control updates the focus path automatically.

Use autofocus for the one control that should be ready when a screen or dialog first appears:

TextInput(
autofocus: true,
placeholder: 'Search files',
onChanged: filterFiles,
)

Autofocus is scoped and one-shot. It fills an empty focus scope when the widget appears; it does not steal focus again on every rebuild.

Wrap a custom control in Focus when it should receive keys and participate in traversal:

Focus(
autofocus: true,
child: TimelineEditor(),
)

Keys travel from the focused node up through its ancestors, so wrap the Focus itself: in a KeyDetector for the control’s own key handling, or in KeyBindings for declared commands. A detector placed inside the Focus never sees the keys.

Own a FocusNode when an action should move focus to a specific control. In the demo, press Enter on Focus search, then type a query: the action moves focus straight to the search field, without stepping through the controls between them.

focus_tour.dart
You write · editable
class _ProgrammaticFocusTourState extends State<_ProgrammaticFocusTour> {
final _searchFocus = FocusNode(debugLabel: 'search');
String _lastAction = 'Focus search is focused';
void _focusSearch() {
_searchFocus.requestFocus();
setState(() => _lastAction = 'Focus moved to Search files');
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('PROGRAMMATIC FOCUS', style: CellStyle(bold: true)),
const Text(
'Press Enter on Focus search, then type.',
style: CellStyle(dim: true),
),
const SizedBox(height: 1),
Row(
children: [
Button(
text: 'Focus search',
autofocus: true,
onPressed: _focusSearch,
),
const SizedBox(width: 2),
SizedBox(
width: 24,
child: TextInput(
focusNode: _searchFocus,
semanticLabel: 'Search files',
placeholder: 'Search files',
onChanged: (query) => setState(() {
_lastAction = 'Searching for "$query"';
}),
),
),
],
),
const SizedBox(height: 1),
Text('last: $_lastAction'),
],
);
@override
void dispose() {
_searchFocus.dispose();
super.dispose();
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

A node is an owned resource: create it once, keep it across rebuilds, and dispose it with the state that owns it. Use hasFocus when behavior depends on that exact target. Built-in controls accept a focusNode; wrap a custom target in Focus(focusNode: ...) instead.

Use FocusDetector to react when focus crosses the boundary of a whole subtree. It is useful for active-pane styling, cursor visibility, and pausing an inactive preview:

focus_detector.dart
You write · editable
class _FocusDetectorTourState extends State<_FocusDetectorTour> {
bool _inside = false;
int _changes = 0;
void _onFocusChange(bool inside) => setState(() {
_inside = inside;
_changes++;
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'FOCUSDETECTOR · ONE SUBTREE BOUNDARY',
style: CellStyle(bold: true),
),
Text(
'editor: ${_inside ? 'ACTIVE' : 'inactive'} · '
'boundary changes: $_changes',
),
const SizedBox(height: 1),
FocusDetector(
onFocusChange: _onFocusChange,
// The border follows the detector: accented while focus is inside.
child: Container(
border: BoxBorder(
style: theme.borderStyle,
cellStyle: _inside
? CellStyle(foreground: theme.colorScheme.primary)
: theme.mutedStyle,
),
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Editor region', allowSelect: false),
Button(text: 'Title', autofocus: true, onPressed: () {}),
Button(text: 'Body', onPressed: () {}),
],
),
),
),
const SizedBox(height: 1),
Button(text: 'Preview (outside)', onPressed: () {}),
const Text(
'Tab Title → Body: same region · Preview: leaves once',
style: CellStyle(dim: true),
),
],
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

Tab from Title to Body: both controls remain inside the editor, so the boundary counter does not change. Tab to Preview and the detector reports one leave. This avoids false leave-and-enter cycles when focus merely moves between children of the same region.

FocusDetector and Focus.of(context).hasFocus answer different questions:

  • FocusDetector reports whether anything inside a region has focus. Moving between its children is not a leave, which makes it the active-pane signal. Detectors nest like CSS :focus-within: every detector around the focused widget reports focus, so a Panel still highlights when its content uses a detector of its own.
  • Focus.of(context).hasFocus is true only while the nearest Focus is the focused node. Inside a pane of buttons or inputs, it is false while a child holds the keyboard. Use it when the region is itself one focus stop, such as a list or a slider.

Reading Focus.of during build subscribes the widget, so it rebuilds when that node gains or loses focus without a detector or setState. FocusManager.of(context) returns the application’s focus manager, which knows what has focus anywhere, and rebuilds the reader on every focus move. KeyBindings and KeyDetector are not focus targets; Focus.of skips past them to the enclosing Focus.

A list is one focus stop with a row cursor. highlighted means current and stays true when the keyboard leaves; Focus.of(context).hasFocus is whether the list itself holds the keyboard. ListView already paints the theme selection style on the current row, so a two-state look — marker always, fill only while focused — has to replace that default rather than merge onto it:

ListView.builder(
focusNode: listFocus,
itemCount: keys.length,
itemBuilder: (context, index, highlighted) {
final theme = Theme.of(context);
final focused = highlighted && Focus.of(context).hasFocus;
return DefaultTextStyle(
style: focused
? theme.textStyle.merge(theme.selectionStyle)
: theme.textStyle,
child: Row(
children: [
Text(highlighted ? '> ' : ' '),
Text(keys[index]),
],
),
);
},
)

Overlay entries such as menus, toasts, and the error overlay sit beside the app root rather than under it. Inside an entry with no Focus of its own, Focus.maybeOf returns null and Focus.of throws, while FocusManager.of still works.

Each screen and dialog gets its own FocusScope. A scope remembers the last control focused inside it, so returning to a screen restores the user’s place. Opening another screen or dialog activates its scope; closing it restores the previous screen’s remembered control.

context.present also traps focus inside the dialog. Tab, arrow traversal, clicks, and direct focus requests cannot move focus to the covered screen, and unmatched keys do not fall through to it. The Publish… flow in the first demo shows both behaviors: focus starts on Cancel, stays in the dialog, and returns to Publish… when the dialog closes.

final confirmed = await context.present<bool>(
const ConfirmPublishDialog(),
);
if (confirmed == true) publish();

Pages pushed with Navigator.push get the same focus memory and restoration, without trapping app-level focus or shortcuts as a dialog would.

Most applications never configure a focus trap directly. Use FocusScope(trapFocus: true) only for a custom overlay that does not go through context.present:

FocusScope(
trapFocus: true,
child: CommandPalette(),
)

Every FocusScope remembers its last focused descendant. trapFocus adds the stronger rule that focus cannot leave the subtree; it does not swallow key events. When unmatched keys must not reach the app behind the overlay either, also wrap it in KeyBindings(modal: true). context.present applies both for you.

Every screen and dialog hosted by FleuryApp already gets the traversal behavior shown above. FocusTraversalGroup is the lower-level widget that provides that policy, not an extra boundary that ordinary panes and toolbars need to add.

Neither runApp nor mountApp installs one. Create one directly only for a low-level tree passed to either without FleuryApp or Navigator:

runApp(FocusTraversalGroup(child: EmbeddedSurface()));

These APIs answer different questions:

// Clickable or programmatically focusable, but skipped by traversal.
final _helpFocus = FocusNode(skipTraversal: true);
Button(text: 'Help', focusNode: _helpFocus, onPressed: showHelp);
// Cannot receive focus by traversal, click, or requestFocus().
Focus(canRequestFocus: false, child: DecorativeCanvas());
// Remove an entire hidden or inactive subtree from focus.
ExcludeFocus(child: OffstagePage());

The flags belong to the node that takes focus. A built-in control owns its node, so give it a FocusNode created with the flag, and own and dispose it like any other node. Wrapping the control in Focus(skipTraversal: true) would only flag the wrapper. Use the Focus flags for a custom target that has no node of its own, and ExcludeFocus for a whole inactive page. Skipping traversal does not disable pointer input.

APIWhat it does
FocusMakes a custom subtree focusable. Focus.of returns its node.
FocusNodeOwns a target you can request, inspect, and dispose.
FocusDetectorReports focus entering or leaving a subtree.
FocusManager.ofThe application’s focus manager.
FocusScopeRemembers focus within a subtree; trapFocus can contain it.
FocusTraversalGroupInstalls Tab and arrow traversal for a bare root.
ExcludeFocusRemoves a whole subtree from every focus path.