⌨️ Keyboard access
Common mistake
A common mistake is building functionality that only works with touch or pointer input, implementing keyboard traps that prevent users from leaving a component, or using single-character shortcuts that fire unintentionally during speech input or normal typing. Together these issues make an application unusable for anyone who relies on a keyboard or switch device.
🎯 Relevant elements
This guideline applies to the application as a whole and any component that handles keyboard input:
- All interactive widgets (
GestureDetector,InkWell, custom widgets)- Overlays, dialogs, bottom sheets, and modals
- Any
KeyboardListener,Focus, orShortcutswidget- Global or scoped keyboard shortcut registrations
WCAG Guidelines
This guideline covers three related criteria:
- WCAG 2.2 - 2.1.1 Keyboard (Level A). All functionality must be operable through a keyboard interface, without requiring specific timings for individual keystrokes.
- WCAG 2.2 - 2.1.2 No Keyboard Trap (Level A). If keyboard focus can be moved to a component, focus must be movable away from it using only the keyboard. If non-standard keys are needed to move focus, the user must be informed.
- WCAG 2.2 - 2.1.4 Character Key Shortcuts (Level A). If a shortcut uses only a single character key (letter, number, punctuation, or symbol), it must be possible to turn it off, remap it to include a modifier key, or restrict it so it is only active when the relevant component has focus.
Solution
Keyboard operability (2.1.1)
Every action reachable by touch must also be reachable by keyboard. In Flutter, most standard widgets (ElevatedButton, TextField, etc.) handle this automatically. For custom interactive widgets, ensure they can receive focus and respond to key events.
// ❌ Don't - GestureDetector alone is not keyboard accessible
GestureDetector(
onTap: () => selectItem(),
child: MyCustomCard(),
);
// ✅ Do - wrap in a focusable widget that responds to Enter/Space
Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space)) {
selectItem();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: GestureDetector(
onTap: () => selectItem(),
child: MyCustomCard(),
),
);
No keyboard trap (2.1.2)
When a modal or overlay opens, focus should move into it. When it closes, focus must return to the element that triggered it. Never leave the user stranded inside a component with no keyboard escape.
// ❌ Don't - custom overlay steals focus with no way to dismiss via keyboard
showOverlay() {
Navigator.of(context).push(
PageRouteBuilder(pageBuilder: (_, __, ___) => MyOverlay()),
);
// Focus moves into overlay but Escape is not handled — user is trapped
}
// ✅ Do - handle Escape to close and return focus to the trigger
Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: MyOverlayContent(),
);
Flutter’s AlertDialog and ModalBottomSheet handle focus trapping and Escape dismissal correctly by default.
Character key shortcuts (2.1.4)
Prefer shortcuts that require a modifier key (Ctrl, Alt, or Shift). If a single-character shortcut is necessary, scope it to the component that owns it so it only fires when that component has focus.
// ❌ Don't - single-character global shortcut triggers during speech input
// or when the user types that character anywhere
Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyS) {
openSearch();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: MyApp(),
);
// ✅ Do - require a modifier key
Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyS &&
HardwareKeyboard.instance.isControlPressed) {
openSearch();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: MyApp(),
);
Flutter’s Shortcuts and Actions widgets are the recommended way to declare shortcuts declaratively and scope them correctly:
Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS):
SearchIntent(),
},
child: Actions(
actions: {SearchIntent: SearchAction()},
child: MyApp(),
),
);
Validation & Testing
See the Validation & Testing setup guide for tool configuration.
Manual testing
- Connect a physical keyboard and navigate the entire app using only Tab, Shift+Tab, Enter, Space, and Escape.
- Verify every interactive element is reachable and operable.
- Open each modal or overlay and confirm focus moves in, and that Escape closes it and returns focus to the trigger.
- Focus a text input and type a sentence containing any shortcut character — verify the shortcut does not fire.
TalkBack & VoiceOver
Navigate using only the keyboard/switch. Verify no focus traps occur and all actions are reachable.
flutter_test
testWidgets('custom card is keyboard operable', (tester) async {
bool selected = false;
await tester.pumpWidget(MaterialApp(
home: MyCustomCard(onSelect: () => selected = true),
));
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(selected, isTrue);
});
testWidgets('search shortcut requires Ctrl modifier', (tester) async {
bool searchOpened = false;
await tester.pumpWidget(
MaterialApp(home: MyApp(onSearch: () => searchOpened = true)),
);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pumpAndSettle();
expect(searchOpened, isFalse);
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
await tester.pumpAndSettle();
expect(searchOpened, isTrue);
});
🛠️ Usage baseflow-a11y-components library
🛠️ Work in progress…