Godot 4 UI: one Theme, and a pause menu that pauses
Build a Godot 4 UI theme once — fonts, StyleBoxes, type variations — and a pause menu that stops the tree, keeps the menu alive, and does not leak focus back into the game.
Basic theme setupTwo things break Godot UI projects at the same size: per-node overrides that drift, and a pause menu that pauses everything, including itself. Both are fixable with the same discipline — put the decision in one resource, and put the state in one place.
1. One Theme resource, no per-node overrides
A Theme on the root Control (or in Project Settings → GUI → Theme) supplies fonts, colours
and StyleBoxes to every descendant. A per-node override wins over the theme and is invisible
in the file you are editing, which is why a button ends up the wrong colour three scenes later.
# theme_builder.gd — run once, save the .tres, delete the script.
const ACCENT := Color("e8b04b")
const INK := Color("1d1b18")
static func make_theme(base_font: Font) -> Theme:
var theme := Theme.new()
theme.default_font = base_font
theme.default_font_size = 16
var normal := StyleBoxFlat.new()
normal.bg_color = ACCENT
normal.set_corner_radius_all(6)
normal.content_margin_left = 18
normal.content_margin_right = 18
normal.content_margin_top = 10
normal.content_margin_bottom = 10
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = ACCENT.lightened(0.12)
theme.set_stylebox("normal", "Button", normal)
theme.set_stylebox("hover", "Button", hover)
theme.set_stylebox("pressed", "Button", hover)
theme.set_color("font_color", "Button", INK)
return themeBuild it once and commit the .tres. Regenerating a theme at runtime hides exactly the kind
of drift a designer would catch in the inspector.
2. Type variations instead of duplicate styles
Need a secondary button? Do not style it twice. Add a type variation:
var ghost := StyleBoxFlat.new()
ghost.bg_color = Color(1, 1, 1, 0.08)
ghost.set_corner_radius_all(6)
theme.set_type_variation("GhostButton", "Button")
theme.set_stylebox("normal", "GhostButton", ghost)Then set theme_type_variation = "GhostButton" on the node. One theme, two looks, and the
variation inherits every property you did not override. Copying the whole StyleBox instead
means the corner radius changes in one place and not the other.
3. Pause: stop the tree, keep the menu
get_tree().paused = true stops every node whose process_mode is PAUSABLE — the default.
So the menu has to opt out, and the obvious fix (set the menu to ALWAYS) is too broad: it
keeps the menu's animations, timers and audio running while the game is frozen.
@onready var menu: Control = $PauseMenu
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("pause"):
_set_paused(not get_tree().paused)
func _set_paused(paused: bool) -> void:
get_tree().paused = paused
menu.visible = paused
if paused:
menu.get_node("%Resume").grab_focus()Give the menu process_mode = PROCESS_MODE_WHEN_PAUSED. It then runs only while the tree is
paused, its AnimationPlayer freezes with the game the rest of the time, and anything that must
keep running during pause (a music bus, a pause-screen shader) opts in individually with
ALWAYS.
4. Focus is the second pause bug
A hidden Control with focus still consumes input. After unpausing, the first click in the game
often does nothing because the resume button still holds focus.
- On unpause,
get_viewport().gui_release_focus(). - On pause,
grab_focus()on the resume button so a controller can leave the menu. - Set explicit
focus_neighbor_*paths on the menu buttons. Automatic neighbour search follows tree order, which puts the quit button next to resume more often than anyone expects.
func _set_paused(paused: bool) -> void:
get_tree().paused = paused
menu.visible = paused
if paused:
menu.get_node("%Resume").grab_focus()
else:
get_viewport().gui_release_focus()
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) # only for mouse-look games5. Do not pause the audio you want to keep
AudioStreamPlayer is a node with a process mode like any other. Music should survive a pause
screen; a footstep should not.
| Node | Process mode during pause |
|---|---|
| Music player on its own bus | ALWAYS |
| Gameplay SFX | PAUSABLE (default) |
| Pause menu root and its buttons | WHEN_PAUSED |
| Pause shader / colour rect | WHEN_PAUSED |
Common mistakes
paused = truewith the menu alsoPAUSABLE. The menu appears and nothing responds; it looks like a broken button, not a process mode.- Resuming from a button signal while the tree is paused. Only works because the menu is
WHEN_PAUSED; aPAUSABLEmenu never receives the press. - Leaving focus on the resume button. One swallowed click after every pause, blamed on the input map.
- Per-node
theme_override_*. The quickest edit and the slowest to undo across scenes. - Pausing in
_process. Two frames can pass before it applies; use_unhandled_inputfor the key and set the state there.