Keyboard Events in Flutter: From RawKeyboardListener to KeyboardListener
RawKeyboardListener is deprecated after Flutter v3.18; use KeyboardListener, Focus.onKeyEvent, and HardwareKeyboard to handle keys.
Published on • November 5, 2026
AI Assistant

If you have ever built a desktop or web Flutter app, you know the moment: the UI looks perfect, but the arrow keys do nothing until something in the widget tree actually holds keyboard focus. Keyboard input always travels through Flutter’s focus system, and the widget used to listen for it has changed — RawKeyboardListener is officially deprecated.
Key events always start with focus
The focus system guide explains the model: a sparse focus tree mirrors your widget tree, and key events begin at the node with primary focus and bubble toward the root. If no node handles the event, it is returned to the platform.
A few rules from that guide are worth memorizing:
- Never allocate a new
FocusNodeinbuild()— create it inState.initState()and dispose it indispose(). - Set
onKeyEventon theFocuswidget rather than on the node, because a later rebuild can overwrite a node-level handler. - Focus key events are processed before text entry, so a handler around a text field can swallow keys before they reach it.
- Call
focusNode.requestFocus()directly; it is equivalent toFocusScope.of(context).requestFocus(...)and more performant.
RawKeyboardListener is deprecated
The RawKeyboardListener API page is blunt about it: the widget “is deprecated and will be removed. Use KeyboardListener instead.” Its annotation reads @Deprecated('Use KeyboardListener instead. This feature was deprecated after v3.18.0-2.0.pre.').
The old widget took an onKey callback of type ValueChanged<RawKeyEvent> — the legacy event type built on RawKeyboard and RawKeyEventData. The whole RawKeyboard/RawKeyEvent family is the pre-3.18 API; the framework now centers on HardwareKeyboard and KeyEvent.
KeyboardListener: the drop-in replacement
The KeyboardListener API keeps the same shape but swaps the callback type. It is a StatelessWidget with focusNode, autofocus, includeSemantics, and onKeyEvent of type ValueChanged<KeyEvent>:
final _focusNode = FocusNode(debugLabel: 'game');
KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (event) {
if (event is KeyDownEvent) {
debugPrint('down: ${event.logicalKey}');
}
},
child: const GameView(),
)
If you need to both handle keys and stop them from reaching ancestors, wrap a subtree in Focus and return KeyEventResult.handled from its onKeyEvent instead:
Focus(
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.escape) {
_pauseGame();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: child,
)
Physical keys, logical keys, and characters
A KeyEvent carries physicalKey, logicalKey, character, timeStamp, deviceType, and synthesized. The difference matters:
physicalKeyis the location on the board: on a French AZERTY layout, the top-left letter key reportsPhysicalKeyboardKey.keyA.logicalKeyis what that key means in the active layout: the same key reportsLogicalKeyboardKey.keyQon French.characteris the grapheme cluster produced by the keystroke, so Shift+A yields"A"while A alone yields"a".
For games, match on logicalKey for what the player intends (arrow keys mean arrows on every layout) or on physicalKey for positional binds (WASD stays in the same physical spots).
HardwareKeyboard vs RawKeyboard
HardwareKeyboard is the singleton behind ServicesBinding.keyboard. It guarantees a regularized stream: each key tap is one KeyDownEvent, zero or more KeyRepeatEvents, and one KeyUpEvent with the same physical and logical key. It also records state you can query at any time:
final kb = HardwareKeyboard.instance;
// Inside a key event handler, pressed state is already updated.
if (kb.isLogicalKeyPressed(LogicalKeyboardKey.shiftLeft)) {
_sprintMode = true;
}
final held = kb.physicalKeysPressed; // Set<PhysicalKeyboardKey>
Two caveats from the docs: events can be synthesized (KeyEvent.synthesized) when Flutter resynchronizes after startup or a focus change, and multiple physical keyboards are merged into a single event stream. Handlers registered with HardwareKeyboard.instance.addHandler must be removed with removeHandler; for UI-scoped listening, prefer the Focus widget’s onKeyEvent.
Example: arrow-key game controls
class ArrowKeyControls extends StatefulWidget {
const ArrowKeyControls({super.key, required this.onMove});
final void Function(Offset delta) onMove;
@override
State<ArrowKeyControls> createState() => _ArrowKeyControlsState();
}
class _ArrowKeyControlsState extends State<ArrowKeyControls> {
final _focusNode = FocusNode(debugLabel: 'arrow-keys');
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
const step = 24.0;
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowUp:
widget.onMove(const Offset(0, -step));
case LogicalKeyboardKey.arrowDown:
widget.onMove(const Offset(0, step));
case LogicalKeyboardKey.arrowLeft:
widget.onMove(const Offset(-step, 0));
case LogicalKeyboardKey.arrowRight:
widget.onMove(const Offset(step, 0));
default:
return KeyEventResult.ignored;
}
return KeyEventResult.handled; // stop Tab traversal from eating the key
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _handleKey,
child: const GameCanvas(),
);
}
}
Handling KeyRepeatEvent as well as KeyDownEvent gives smooth held-key movement, while returning handled prevents the same press from also triggering focus traversal.
Takeaway: Replace every RawKeyboardListener/onKey pair with KeyboardListener or Focus.onKeyEvent, match on logicalKey for intent and physicalKey for position, and query HardwareKeyboard.instance for live pressed state — the deprecated RawKeyboard API is on its way out after v3.18.