Game Development with Godot & GDScript Module 11 – UI, menus, accessibility and localization
Focus navigation and accessibility
Make Godot 4.7 menus work with keyboard and controller focus, fix the focus neighbours Godot guesses, name controls for screen readers and check colours.
What you will learn
- Build menus that keyboard and controller players can use from the moment they open
- Set focus neighbours where Godot guesses wrong, and keep the focus when a control hides
- Name icon-only controls and live text for screen readers with the accessibility properties
- Check the contrast and colour-blind safety of a HUD palette in code
Before you start
What you’ll learn · Before you start
What you will learn
- Build menus that keyboard and controller players can use from the moment they open
- Set focus neighbours where Godot guesses wrong, and keep the focus when a control hides
- Name icon-only controls and live text for screen readers with the accessibility properties
- Check the contrast and colour-blind safety of a HUD palette in code
Before you start
On this page
Two presses of down on a controller moved nothing in this page’s first run, because no control of the pause menu had the focus. Menus only ever tried with a mouse fail this way, as do icon buttons a screen reader cannot name and health bars that turn from green to red for players who cannot tell the two apart.
This page gives a menu its first focus, walks focus navigation with the ui_* actions, fixes the neighbours Godot guesses wrong and audits a HUD for controls without a name. Every run is headless Godot 4.7.2, with key presses sent from code.
Nothing works with a controller until one control has the focus
The arrow keys, Tab and a controller’s d-pad do not move a cursor in a Godot menu; they move the
focus: The one control that receives keyboard and controller input, such as the highlighted button of a menu. The ui_* actions move it to a neighbour, grab_focus() gives it to a control, and a control whose focus_mode is FOCUS_NONE never gets it., through the built-in actions ui_up, ui_down, ui_focus_next and the others.
focus_walk.gd opens a pause menu twice, first with its _ready() removed and then as written, and presses those
actions:
extends CenterContainer
## A pause menu that a keyboard or a controller can use at once: Resume takes the focus when the menu
## opens, after the containers have placed it.
func _ready() -> void:
$Column/Resume.grab_focus.call_deferred() extends SceneTree
## A pause menu with and without a focused control, walked with the ui_* actions that the arrow keys,
## Tab and a controller's d-pad send.
func press(action: StringName) -> void:
for pressed in [true, false]:
var event := InputEventAction.new()
event.action = action
event.pressed = pressed
Input.parse_input_event(event)
await process_frame
func focused() -> String:
var owner := root.gui_get_focus_owner()
return owner.name if owner else "nothing"
func walk(actions: Array[StringName]) -> String:
var steps: PackedStringArray = [focused()]
for action in actions:
await press(action)
steps.append(focused())
return " -> ".join(steps)
func _initialize() -> void:
await process_frame
root.size = Vector2i(1920, 1080)
var menu: Control = load("res://pause_menu.tscn").instantiate()
menu.set_script(null)
root.add_child(menu)
await process_frame
print("Without grab_focus(): ui_down, ui_down: %s" % await walk([&"ui_down", &"ui_down"]))
menu.free()
menu = load("res://pause_menu.tscn").instantiate()
root.add_child(menu)
await process_frame
print("With grab_focus() in _ready():")
print(" ui_down x3: %s" % await walk([&"ui_down", &"ui_down", &"ui_down"]))
print(" ui_up x3: %s" % await walk([&"ui_up", &"ui_up", &"ui_up"]))
print(" ui_focus_prev, ui_focus_next: %s" % await walk([&"ui_focus_prev", &"ui_focus_next"]))
var title: Label = menu.get_node("Column/Title")
var resume: Button = menu.get_node("Column/Resume")
print("focus_mode: Title %d (FOCUS_NONE), Resume %d (FOCUS_ALL)" % [title.focus_mode, resume.focus_mode])
resume.grab_focus(true)
print("grab_focus(true), as after a mouse click: has_focus() %s, has_focus(true) %s" % [resume.has_focus(), resume.has_focus(true)])
menu.free()
quit() from session import files, run
# A pause menu walked with the ui_* actions, without and with a control that has the focus.
files("project.godot", "focus/pause_menu.tscn=pause_menu.tscn", "focus/pause_menu.gd=pause_menu.gd", "focus/focus_walk.gd=focus_walk.gd")
run("""
godot --headless --no-header --script focus_walk.gd
""") Output
$ godot --headless --no-header --script focus_walk.gd Without grab_focus(): ui_down, ui_down: nothing -> nothing -> nothing With grab_focus() in _ready(): ui_down x3: Resume -> Options -> Quit -> Quit ui_up x3: Quit -> Options -> Resume -> Resume ui_focus_prev, ui_focus_next: Resume -> Quit -> Resume focus_mode: Title 0 (FOCUS_NONE), Resume 2 (FOCUS_ALL) grab_focus(true), as after a mouse click: has_focus() true, has_focus(true) false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 walk_session.py
Without a focused control, two presses of ui_down did nothing. The navigation page says some control must be
focused by code when the scene starts, and its example calls grab_focus.call_deferred() in _ready(), as
pause_menu.gd does. Follow the run:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- No focus. Without a grab_focus() call nothing has the focus, and two presses of ui_down moved nothing: a controller player sees a menu that ignores them.
- Resume takes the focus. pause_menu.gd calls $Column/Resume.grab_focus.call_deferred() in _ready(), so Resume has the focus once the containers have placed it.
- Down the column. ui_down moved the focus to Options, then to Quit. Godot skipped Title because a Label cannot take the focus.
- The end of the column. One more ui_down left the focus on Quit. Arrow keys and the d-pad stop at the last control unless you set a neighbour that wraps.
- Tab goes round. ui_focus_prev, Shift and Tab, from Resume went round to Quit, and ui_focus_next came back: the Tab order wraps, the arrows do not.
focus_mode decides which controls can be focused: Label is FOCUS_NONE, so the walk skipped Title, and Button is
FOCUS_ALL. Since 4.6 a mouse click no longer shows the focus outline: grab_focus(true) gave Resume hidden focus,
which has_focus(true) reports as false.
Testing a menu with the mouse only
A menu clicked through with a mouse works with nothing focused. Press ui_down first, as the run did: a menu that
answers nothing lacks its grab_focus() call.
Checkpoint
Check: The first focus and the ui_* actions
Godot guesses each focus neighbour from where controls sit
The Control reference gives every control four focus neighbour: The control that the focus moves to when the player presses up, down, left or right, set by focus_neighbor_top, _bottom, _left and _right. When one is empty, Godot guesses it from where the controls are.,
focus_neighbor_left, _top, _right and _bottom, plus focus_next and focus_previous for Tab. When they are
empty, the navigation page says Godot “will try to guess the next control automatically”. neighbours.gd prints the
guesses for an inventory of 12 slots in a grid with three buttons beside it, fixes three, and then hides the focused
slot:
extends SceneTree
## The neighbours Godot guesses for an inventory of 12 slots and a column of three buttons, three of
## them fixed by hand, and the focus lost when the focused slot is hidden.
const SIDES := {SIDE_LEFT: "left", SIDE_TOP: "up", SIDE_RIGHT: "right", SIDE_BOTTOM: "down"}
func press(action: StringName) -> void:
for pressed in [true, false]:
var event := InputEventAction.new()
event.action = action
event.pressed = pressed
Input.parse_input_event(event)
await process_frame
func guess(control: Control, side: Side) -> String:
var next := control.find_valid_focus_neighbor(side)
return next.name if next else "nothing"
func report(inventory: Control, paths: Array[String]) -> void:
for path in paths:
var control: Control = inventory.get_node(path)
var parts: PackedStringArray = []
for side: Side in SIDES:
parts.append("%s %s" % [SIDES[side], guess(control, side)])
print(" %-6s %s" % [control.name, ", ".join(parts)])
func _initialize() -> void:
await process_frame
root.size = Vector2i(1920, 1080)
var inventory: Control = load("res://inventory.tscn").instantiate()
root.add_child(inventory)
await process_frame
var watched: Array[String] = ["Slots/Slot4", "Slots/Slot8", "Slots/Slot12", "Actions/Equip", "Actions/Drop", "Actions/Close"]
print("Guessed by Godot:")
report(inventory, watched)
var slot8: Button = inventory.get_node("Slots/Slot8")
var drop: Button = inventory.get_node("Actions/Drop")
var close: Button = inventory.get_node("Actions/Close")
print("Middles, from the top: Slot8 %d, Drop %d, Close %d" % [slot8.get_global_rect().get_center().y,
drop.get_global_rect().get_center().y, close.get_global_rect().get_center().y])
slot8.focus_neighbor_right = slot8.get_path_to(drop)
drop.focus_neighbor_left = drop.get_path_to(slot8)
close.focus_neighbor_bottom = close.get_path_to(close)
print("With three neighbours set by hand:")
report(inventory, ["Slots/Slot8", "Actions/Drop", "Actions/Close"])
var slot5: Button = inventory.get_node("Slots/Slot5")
slot5.grab_focus()
await process_frame
slot5.hide()
await process_frame
var owner := root.gui_get_focus_owner()
print("Slot5 had the focus and was hidden: the focus is on %s" % (owner.name if owner else "nothing"))
await press(&"ui_right")
owner = root.gui_get_focus_owner()
print(" ui_right then reaches %s" % (owner.name if owner else "nothing"))
quit() from session import files, run
# Guessed focus neighbours of an inventory, three set by hand, and the focus lost when a slot is hidden.
files("project.godot", "focus/inventory.tscn=inventory.tscn", "focus/neighbours.gd=neighbours.gd")
run("""
godot --headless --no-header --script neighbours.gd
""") Output
$ godot --headless --no-header --script neighbours.gd Guessed by Godot: Slot4 left Slot3, up nothing, right Equip, down Slot8 Slot8 left Slot7, up Slot4, right Close, down Slot12 Slot12 left Slot11, up Slot8, right Close, down nothing Equip left Slot4, up nothing, right nothing, down Drop Drop left Slot4, up Equip, right nothing, down Close Close left Slot8, up Drop, right nothing, down Slot8 Middles, from the top: Slot8 200, Drop 164, Close 208 With three neighbours set by hand: Slot8 left Slot7, up Slot4, right Drop, down Slot12 Drop left Slot8, up Equip, right nothing, down Close Close left Slot8, up Drop, right nothing, down Close Slot5 had the focus and was hidden: the focus is on nothing ui_right then reaches nothing
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 neighbours_session.py
Most guesses were right; three were not what the screen means:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The guesses. No neighbour is set, so Godot works each one out from where the controls are. Most guesses were right: Slot4 leads right to Equip and Slot12 to Close.
- Slot8, right. From Slot8, in the middle row, right went to Close, whose middle is 8 px from Slot8's, not to Drop, 36 px away. The guess follows the geometry; the design pairs each row with the action beside it.
- Close, down. From Close, the last button, down went back into the grid, to Slot8. A player pressing down to stay at the bottom of the menu lands somewhere else.
- Drop, left. From Drop, left went up to Slot4 instead of across to Slot8, so right and then left does not bring a player back where they started.
- Three neighbours by hand. Setting focus_neighbor_right of Slot8 to Drop, focus_neighbor_left of Drop to Slot8, and focus_neighbor_bottom of Close to Close itself fixed all three.
Good focus navigation is predictable: right and then left comes back, and down at the bottom stays. Set the neighbours where the guess and the design differ, by NodePath, as the run did.
The last lines show the other way focus is lost: when the focused Slot5 was hidden, the focus went to nothing, and so
did ui_right. The navigation page warns that a hidden node can lose the focus, and a controller then does nothing.
Key idea
Give the first control the focus in code, set neighbours where Godot’s guess and the design differ, and move the focus on before a focused control hides.
Checkpoint
Check: Focus neighbours, guessed and set
Screen readers read names, not icons
Godot 4.5 added screen reader support through AccessKit, and its release notes called the integration experimental. A
screen reader: An assistive app that reads out what is on screen and which control has the focus, used by blind and low-vision players. Godot reports each control's name, role and description to it through AccessKit. announces each control by its name, so a Button with text has words to
report and an icon-only button has none of its own. name_audit.gd audits the top bar of a HUD for controls a player
can focus but that have no text and no accessibility_name, names them, and audits again:
extends SceneTree
## An accessibility audit of a HUD's top bar: every control a player can focus needs a name a screen
## reader can read, its text or its accessibility_name. The bar has two buttons with text and three
## with an icon only; the audit runs before and after the icon buttons get names.
func icon() -> Texture2D:
return ImageTexture.create_from_image(Image.create(24, 24, false, Image.FORMAT_RGBA8))
## Every control under root that can take the focus and has no name to read.
func unnamed(root_control: Control) -> PackedStringArray:
var found: PackedStringArray = []
for control: Control in root_control.find_children("*", "Control", true, false):
if control.get_focus_mode_with_override() == Control.FOCUS_NONE:
continue
var text: String = control.get("text") if "text" in control else ""
if text.strip_edges().is_empty() and control.accessibility_name.strip_edges().is_empty():
found.append(control.name)
return found
func _initialize() -> void:
await process_frame
var bar := HBoxContainer.new()
root.add_child(bar)
for spec: Array in [["Resume", "Resume", null], ["Options", "Options", null], ["Map", "", icon()], ["Bag", "", icon()], ["Mute", "", icon()]]:
var button := Button.new()
button.name = spec[0]
button.text = spec[1]
button.icon = spec[2]
bar.add_child(button)
var coins := Label.new()
coins.name = "Coins"
coins.text = "Coins: 120"
bar.add_child(coins)
print("Before: %d controls the player can focus have nothing to read: %s" % [unnamed(bar).size(), ", ".join(unnamed(bar))])
var names := {"Map": ["Map", "Opens the map of the region"], "Bag": ["Inventory", "Opens your bag: 12 slots"], "Mute": ["Mute music", "Turns the music off and on"]}
for button_name: String in names:
var button: Button = bar.get_node(button_name)
button.accessibility_name = names[button_name][0]
button.accessibility_description = names[button_name][1]
button.tooltip_text = names[button_name][0]
coins.accessibility_live = AccessibilityServer.LIVE_POLITE
print("After: %d controls have nothing to read" % unnamed(bar).size())
for control: Control in bar.get_children():
var text: String = control.get("text") if "text" in control else ""
print(" %-7s text \"%s\", accessibility_name \"%s\"" % [control.name, text, control.accessibility_name])
print("Coins: accessibility_live %d, LIVE_POLITE: a screen reader reads each change at its next opportunity" % coins.accessibility_live)
print("Project setting accessibility/general/accessibility_support: %d (0 is Auto)" % ProjectSettings.get_setting("accessibility/general/accessibility_support"))
bar.free()
quit() from session import files, run
# An audit of a HUD's top bar for controls that a screen reader could not name, before and after naming them.
files("project.godot", "names/name_audit.gd=name_audit.gd")
run("""
godot --headless --no-header --script name_audit.gd
""") Output
$ godot --headless --no-header --script name_audit.gd Before: 3 controls the player can focus have nothing to read: Map, Bag, Mute After: 0 controls have nothing to read Resume text "Resume", accessibility_name "" Options text "Options", accessibility_name "" Map text "", accessibility_name "Map" Bag text "", accessibility_name "Inventory" Mute text "", accessibility_name "Mute music" Coins text "Coins: 120", accessibility_name "" Coins: accessibility_live 1, LIVE_POLITE: a screen reader reads each change at its next opportunity Project setting accessibility/general/accessibility_support: 0 (0 is Auto)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 names_session.py
The audit found three: Map, Bag and Mute, icons only. accessibility_name is “the human-readable node name that is
reported to assistive apps”, and accessibility_description a longer explanation. The coin label got accessibility_live set to LIVE_POLITE, which makes it a
live region: A control whose changes a screen reader announces without the focus moving to it, set with accessibility_live: polite changes wait for the next opportunity, assertive ones are read at once.: AccessibilityServer describes polite updates as presented “at the next
opportunity”, so a screen reader reads the new count without breaking off a sentence; LIVE_ASSERTIVE, for a warning
that must interrupt, is presented at once.
Two more questions belong to accessibility, and they are about colour: whether text stands out from what is behind it, measured as a contrast ratio, and whether two colours stay apart for players with colour vision deficiencies. Both answers can be worked out from the colours alone, so a script can check a palette before any player sees it. The accessibility options that players look for, such as a toggle in place of a held button, are a third.
- 4.5
Screen reader support, through AccessKit experimental
Controls report a name, a role and a description to screen readers. The release notes call the integration experimental and say it covered the standard UI nodes, the Project Manager and the inspector at first.
- 4.6
Mouse focus and keyboard focus apart
A click no longer draws the focus outline. grab_focus(true) gives focus without showing it, and has_focus(true) ignores such hidden focus.
- 4.7
Landmark navigation and AccessibilityServer
Screen readers can tell which part of a window the focus is in. Live regions now take AccessibilityServer.AccessibilityLiveMode values, such as LIVE_POLITE.
The timeline has 3 events, from 4.5 to 4.7.
Turn accessibility support on to test it
accessibility/general/accessibility_support is Auto (0) by default: updates are processed only while an assistive
app runs. Inspection tools such as Accessibility Insights do not count, so test with them at Always Active (1).
Checkpoint
Check: Names and live regions for screen readers
Contrast and colour-blind safety, checked in code Pro: opens with a Pro, Premium or Ultimate pass
A palette that looks fine to its designer can fail two ways: text that does not stand out, and colours that look alike to a player with colour vision deficiency. This part measures both in GDScript with the formulas of WCAG 2.2 and a published simulation, and fixes a HUD that fails them.
In this part:
- Contrast ratios the way WCAG computes them
- Health bar colours through protanopia and deuteranopia
2 runnable examples (GD, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Accessibility options players look for, built in a few lines Pro: opens with a Pro, Premium or Ultimate pass
Many barriers come from how a game asks to be played: a button that must be held, a camera that shakes, text that cannot grow. This part builds three options players look for and runs each, at 60 physics ticks a second.
In this part:
- Hold or toggle, chosen by the player
- Screen shake and text size that players choose
2 runnable examples (GD, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Lab: an inventory that a controller and a screen reader can finish Pro: opens with a Pro, Premium or Ultimate pass
An inventory is a hard test of focus navigation: a grid, a column of actions and items that disappear. This lab gives an inventory its first focus, its neighbours and its names, and keeps the focus alive when an item is used up.
In this part:
- The inventory, its checker and a tempting version
- Beyond the lab: focus that players can see and follow
3 runnable examples (GD, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Here the questions keep to focus, neighbours and names, weakest idea first, each choice with its reason.
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: why does a Godot menu do nothing with a gamepad until code calls grab_focus(), and where does the call go?
A gamepad’s d-pad and the arrow keys send the ui_* actions, which move the focus from the control that has it to a
neighbour, so with nothing focused the presses do nothing: in a Godot 4.7.2 run, two presses of ui_down on an
unfocused pause menu moved nothing. The navigation page says a control must be focused by code when the scene starts.
Call grab_focus.call_deferred() on the first button in the menu’s _ready(), and again whenever the menu is shown.
-
Mid to senior
Controller players report that after they use up the last potion in the inventory, no slot is highlighted and the d-pad does nothing until they reopen the menu. What happened, and how do you fix it for every screen of the game?
-
Fresher to mid
A blind player using a screen reader says the buttons of your HUD's top bar give them nothing useful to hear, though the menus with text work. The bar's buttons are icons. How do you fix it and make sure it stays fixed?
-
Mid to senior
Your health bar turns from green to red as health drops, and some players say they cannot tell when they are in danger. How do you test the colours and fix the design?
Key takeaways
- The arrow keys, Tab and the d-pad move the focus through the ui_* actions, so one control must be focused by code before a keyboard or controller can use a menu.
- Godot guesses each empty focus neighbour from where controls sit; set the neighbours where the guess and the design differ, and move the focus on before a focused control hides.
- A screen reader reads names: give every icon-only control an
accessibility_name, and make changing text a live region.
Terms in this lesson
- focus
- The one control that receives keyboard and controller input, such as the highlighted button of a menu. The ui_* actions move it to a neighbour, grab_focus() gives it to a control, and a control whose focus_mode is FOCUS_NONE never gets it.
- focus neighbour
- The control that the focus moves to when the player presses up, down, left or right, set by focus_neighbor_top, _bottom, _left and _right. When one is empty, Godot guesses it from where the controls are.
- live region
- A control whose changes a screen reader announces without the focus moving to it, set with accessibility_live: polite changes wait for the next opportunity, assertive ones are read at once.
- screen reader
- An assistive app that reads out what is on screen and which control has the focus, used by blind and low-vision players. Godot reports each control's name, role and description to it through AccessKit.
References
- Keyboard/Controller Navigation and Focus (Godot Engine)
- Control (class reference): focus and accessibility properties (Godot Engine)
- AccessibilityServer (class reference) (Godot Engine)
- DisplayServer (class reference): accessibility_should_reduce_animation() (Godot Engine)
- ProjectSettings (class reference): accessibility/general/accessibility_support (Godot Engine)
- Engine (class reference): time_scale (Godot Engine)
- Godot 4.5 release notes: screen reader support (Godot Engine)
- Godot 4.6 release notes: separated mouse and keyboard focus (Godot Engine)
- Godot 4.7 release notes: landmark navigation (Godot Engine)
- Upgrading from Godot 4.6 to Godot 4.7: Control (Godot Engine)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- Types of Color Vision Deficiency (National Eye Institute (NIH))
- A Physiologically-based Model for Simulation of Color Vision Deficiency (Machado, Oliveira and Fernandes, IEEE TVCG)
- Color (class reference): get_luminance() and srgb_to_linear() (Godot Engine)
- Xbox Accessibility Guideline 101: text display (Microsoft)
- Xbox Accessibility Guideline 107: input (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress