Skip to content

Articles

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 setup

Two 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 theme

Build 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 games

5. 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 = true with the menu also PAUSABLE. 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; a PAUSABLE menu 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_input for the key and set the state there.