Implementing Keyboard Accessibility in Flutter
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

How to Make Your App More Inclusive
Have you ever tried using your smartphone without touching it? Imagine trying to use an app, but instead of tapping or swiping, you must use the app entirely with an external keyboard. This may seem unconventional to many at first, but others may depend on it.
Accessibility is an important part of developing modern apps not only because of legal requirements but also to improve user experience for all users. Keyboard navigation is a fundamental aspect of app accessibility, as it enables users to interact with applications without relying on touch gestures. This is particularly important for individuals with motor impairments, who may have difficulty using touchscreens, as well as for users who prefer external keyboards for efficiency or convenience. Providing full keyboard accessibility ensures that all interactive elements can be reached and activated, creating a more inclusive and user-friendly experience.
In this article, you will learn how to make your Flutter app keyboard operable to make sure it's completely accessible for those who rely on it. It discusses Flutter classes and widgets that help you to achieve this and what you need to be aware of. All you need to try this out is a keyboard to control the app (or an emulator with keyboard support).
Implementing Keyboard Navigation
Managing Focus and Navigation
Keyboard users typically use the Tab (and Shift+Tab) key to navigate through interactive elements of an app. Every interactive element must be reached with keyboard keys. Some widgets, like TextField, automatically receive focus, while other interactive elements, like buttons, list items, or custom widgets, require a FocusNode to be accessible. A FocusNode is an object that can be used by a stateful widget to manage its focus state. It indicates whether the widget currently has focus or not. You can set or remove the focus of a specific element, react to changes in focus and handle key events.
Focus is a widget that wraps a child widget and manages its focus state. It automatically creates a FocusNode, or you can set a custom FocusNode to observe focus or control the behavior of a widget when it receives or loses focus.
final focusNode = FocusNode();
Focus(
focusNode: focusNode
child: CustomButton(
onPressed: () {
print('Button 1 pressed');
},
),
);
Navigation Order
The order in which interactive elements should receive focus is very important. The navigation order must be logical and intuitive and should follow the visual flow. For most pages, this means top-to-bottom and left-to-right navigation pattern. Use FocusTraversalGroup to group related widgets so that they are traversed in a particular order. It only defined the order within a group but does not restrict movement outside of it. This means that once all elements in the group have been focused, the focus will continue to the next widget outside the group. OrderedTraversalPolicy determines how elements inside the group are traversed. It follows the order defined in the widget tree unless overridden with FocusTraversalOrder. If a custom navigation order is required, use FocusTraversalOrder with NumericFocusOrder to explicitly define the focus sequence.

FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
Row(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextButton(
onPressed: () {},
child: const Text('Button 1'),
),
),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextButton(
onPressed: () {},
child: const Text('Button 2'),
),
),
],
),
Row(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(4),
child: TextButton(
onPressed: () {},
child: const Text('Button 4'),
),
),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: TextButton(
onPressed: () {},
child: const Text('Button 3'),
),
),
],
),
],
),
);
If a stricter focus boundary is needed, you can use FocusScope. This limits certain areas for focus change, meaning that the focus will stay inside the scope unless explicitly moved out. But it's very important to avoid keyboard traps, where users become stuck within a specific element and can't navigate out.

Column(
children: [
FocusScope(
child: Column(
children: [
TextButton(
onPressed: () {},
child: const Text('Button 1'),
),
TextButton(
onPressed: () {},
child: const Text('Button 2'),
),
],
),
),
TextButton(
onPressed: () {},
child: const Text('Button 3'),
),
],
);
Manage Scrollable Content
Scrollable content, such as lists or long texts, must also be navigable via keyboard input. This can be achieved, for example, by using the arrow keys. Flutter's class HardwareKeyboard listens to the input of a physical keyboard. To stay notified whenever keys are pressed, held or released, you can add a handler and react to the event.
The following example demonstrates how you can implement keyboard scrolling. The ScrollController handles the scrolling, while the HardwareKeyboard listener detects arrow key inputs. It allows users to navigate through scrollable content using the arrow keys. By checking scrollController.position.outOfRange before executing the scrolling animation, the scrolling feels more natural, preventing the scroll from stopping abruptly when reaching the start or end of the content.
HardwareKeyboard.instance.addHandler((keyEvent) {
if (keyEvent is! KeyDownEvent || scrollController.position.outOfRange) {
return false;
}
final offset = scrollController.offset;
const scrollDuration = Duration(milliseconds: 300);
const offsetChange = 50;
if (keyEvent.logicalKey == LogicalKeyboardKey.arrowDown) {
scrollController.animateTo(
offset + offsetChange,
duration: scrollDuration,
curve: Curves.linear,
);
} else if (keyEvent.logicalKey == LogicalKeyboardKey.arrowUp) {
scrollController.animateTo(
offset - offsetChange,
duration: scrollDuration,
curve: Curves.linear,
);
}
return false;
});While scrolling, it can happen that you want to continue navigating with tab or shift + tab and the next focused widget is not visible. It must be ensured that the currently focused widget is always visible. A listener can be implemented on the FocusManager for this purpose. FocusManager is a singleton that is responsible for tracking which FocusNode has the primary focus, the order of the nodes and distributing key events to the node in the focus tree.
FocusManager.instance.addListener(() {
final focusedNodeContext = FocusManager.instance.primaryFocus?.context;
if (focusedNodeContext == null || !context.mounted) {
return;
}
Scrollable.ensureVisible(
focusedNodeContext,
curve: Curves.linear,
duration: _scrollDuration,
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
}); Custom Keyboard Shortcuts
For widgets not reachable via Tab or Shift + Tab, custom keyboard shortcuts can be defined. This can be achieved by adding listeners for specific keyboard keys or by using Flutter's Shortcuts widget. Note that keyboard shortcuts only work when the widget currently has focus.
The Shortcuts widget maps a LogicalKeySet (a combination of one or more keys) to an Intent. The Actions widget then maps the Intent to an Action. When the key combination is pressed, the Shortcuts widget triggers the corresponding Intent, which the Actions widget translates into an action by invoking it's invoke() method.
The following example shows how keyboard shortcuts can be used in a text editor with the Quill package. The user can format text (e.g., making it bold) but simply using the Tab key to reach the buttons is not an option because it would just indent the text instead of moving focus. Therefore, keyboard shortcuts need to be added (CTRL + B) to allow formatting actions without affecting text input. In addition to that, a listener for the escape key is implemented to exit the editor and move focus to the next widget.

class CustomQuillEditor extends StatelessWidget {
final QuillController controller;
final FocusNode focusNode;
final FocusNode nextFocusNode;
const CustomQuillEditor({
required this.controller,
required this.focusNode,
required this.nextFocusNode,
super.key,
});
@override
Widget build(BuildContext context) {
HardwareKeyboard.instance.addHandler((keyEvent) {
if (keyEvent is! KeyDownEvent) {
return false;
}
if (focusNode.hasFocus && keyEvent.logicalKey == LogicalKeyboardKey.escape) {
FocusScope.of(context).unfocus();
nextFocusNode.requestFocus();
}
return false;
});
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB):
const _TextEditingIntent(Attribute.bold),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI):
const _TextEditingIntent(Attribute.italic),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyU):
const _TextEditingIntent(Attribute.underline),
},
child: Actions(
actions: <Type, Action<Intent>>{
_TextEditingIntent: _TextEditingAction(controller),
},
child: QuillEditor(
focusNode: focusNode,
controller: controller,
scrollController: ScrollController(),
),
),
);
}
}
class _TextEditingIntent extends Intent {
final Attribute attribute;
const _TextEditingIntent(this.attribute);
}
class _TextEditingAction extends Action<_TextEditingIntent> {
_TextEditingAction(this.controller);
final QuillController controller;
@override
void invoke(covariant _TextEditingIntent intent) {
controller
..skipRequestKeyboard = !intent.attribute.isInline
..formatSelection(intent.attribute);
}
} Conclusion
Keyboard navigation is an essential aspect of mobile app accessibility, playing an important role in creating inclusive user experiences. By ensuring that all interactive elements are focusable, users with motor impairments or those relying on external keyboards can interact with the app without barriers. Maintaining a logical focus order contributes to an intuitive navigation flow, while managing scrollable content ensures that even non-tappable elements remain accessible. Custom keyboard shortcuts enhance usability by allowing quick access to common actions. Flutter provides powerful tools such as Focus, FocusTraversalGroup, Shortcuts, and Actions to help developers map user intent into concrete actions, enabling a seamless and accessible keyboard navigation experience.
By implementing these features, developers can significantly improve the app's usability and accessibility for a diverse user base. Let's create impact together and make our apps more accessible for everyone! Have you ever worked on keyboard accessibility in Flutter? What challenges did you face? Share your thoughts and let's discuss! (By Tabea Schuler)