Godot 4 UI Tutorial — Build Responsive Menus, HUDs
& Inventory Screens
Every game needs a user interface. Health bars, pause menus, inventory grids, settings panels — none of these build themselves. Yet Godot's UI system is one of its most powerful and least understood features. Godot control nodes give you a layout engine that rivals CSS Flexbox, but it works differently enough that most developers struggle with it at first.
In this Godot 4 UI tutorial, you'll learn the entire Control node pipeline from the ground up. We'll cover containers, anchors, responsive layouts, themes, custom fonts, and then build four real-world screens: a health bar HUD, a pause menu, an inventory grid with drag-and-drop, and a settings screen with audio/video sliders. Every example includes complete GDScript code you can paste directly into your project.
If you're new to GDScript patterns, grab our free GDScript Cheat Sheet first. Already comfortable? Let's build.
1 — Control Nodes — The Foundation of Godot UI
In Godot 4, every UI element inherits from Control. Buttons, labels, panels, sliders, texture rects — all of them are godot control nodes under the hood. This means they share a common set of layout properties: position, size, anchors, margins, size flags, and minimum size.
The key concept to internalize is the anchor-margin system. Anchors are normalized values (0.0 to 1.0) that describe where a control's edges attach relative to its parent. Margins are pixel offsets from those anchor points. Together they create responsive layouts that adapt to different screen sizes without you writing a single line of code.
For example, setting all four anchors to 0.5 and adjusting margins centers a control on screen. Setting the right anchor to 1.0 and left to 0.0 stretches a control across the full width. This is conceptually similar to CSS positioning, but Godot handles it in the Inspector via the “Layout” dropdown — presets like “Full Rect”, “Center”, and “Top Wide” set anchor combinations for you.
If you've used Godot's signal bus pattern to decouple game logic, you'll love how UI signals like pressed, value_changed, and text_changed keep your interface equally decoupled.
2 — The Container System — VBox, HBox, Grid & Margin
Manual positioning with anchors works for single elements, but arranging dozens of buttons, labels, and icons by hand is painful. That's where Godot's container nodes come in. They automatically arrange their children according to layout rules:
- VBoxContainer — Stacks children vertically, top to bottom. Perfect for menu button lists and settings panels.
- HBoxContainer — Stacks children horizontally, left to right. Great for toolbars, stat bars, and icon rows.
- GridContainer — Arranges children in a grid with a configurable column count. Ideal for inventory slots and skill trees.
- MarginContainer — Adds configurable padding around a single child. Use it to inset content from screen edges.
You can nest containers freely. A common pattern is MarginContainer → VBoxContainer → multiple HBoxContainer rows. This gives you padded, vertically-stacked rows of horizontally-arranged elements — essentially a responsive grid system.
Each child inside a container has size flags. Set size_flags_horizontal to SIZE_EXPAND_FILL to make a button stretch to fill available space. Combine SIZE_SHRINK_CENTER to center a smaller element inside its allocated space. These flags replace manual sizing and make your layouts responsive by default.
3 — Anchors & Responsive Layouts
For elements that live outside containers — a minimap in the corner, a health bar at the top, or a notification popup — you need to set anchors directly. Here's how to pin a HUD element to the top-left corner via GDScript:
extends Control
func _ready() -> void:
# Pin to top-left corner with 16px padding
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 16
offset_top = 16
offset_right = 200 # 200px wide
offset_bottom = 48 # 48px tall
# For a full-width top bar, use:
# anchor_right = 1.0
# This stretches the control across the entire parent widthFor true responsive design across resolutions, combine anchors with Godot's stretch mode in Project Settings → Display → Window. Set the stretch mode to canvas_items and the aspect to expand. This tells Godot to scale the entire UI canvas and then let anchored elements reposition themselves within the expanded viewport. Your HUD stays pinned to corners, containers reflow, and everything looks correct from 720p to 4K.
Want clean architecture in Godot? Our State Machines in GDScript course covers FSMs, hierarchical states, and game flow — all in 15 minutes.
Want more Godot tricks?
Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.
4 — Theme System & Custom Fonts
Godot's theme system lets you define colors, fonts, font sizes, and styleboxes (backgrounds, borders, shadows) in a single .tres resource. Attach a theme to any Control node and every child inherits it automatically. This is how you achieve a consistent visual style across your entire game without duplicating settings on every button and label.
To add custom fonts, import a .ttf or .otf file into your project. Then open your theme resource and set the font property for each control type. You can also override fonts per-node in the Inspector under “Theme Overrides”.
extends Control
func _ready() -> void:
# Load and apply a custom theme at runtime
var my_theme = load("res://ui/game_theme.tres") as Theme
theme = my_theme
# Override a specific font on this node
var custom_font = load("res://fonts/pixel_font.ttf") as Font
add_theme_font_override("font", custom_font)
add_theme_font_size_override("font_size", 24)
# Override colors for a specific control type
add_theme_color_override("font_color", Color(0.9, 0.95, 1.0))
add_theme_color_override("font_hover_color", Color(0.3, 0.9, 0.5))The most powerful approach is creating your theme in the editor. Open a new Theme resource, add items for Button, Label, Panel, etc., and customize their styleboxes. A StyleBoxFlat gives you rounded corners, borders, gradients, and shadows — enough to create professional-looking UI without any textures. This approach pairs well with @export tips so designers can tweak theme properties from the Inspector.
5 — Building a Health Bar HUD
A godot hud usually starts with a health bar. Godot's ProgressBar and TextureProgressBar are built for this. We'll create a smooth, animated health bar that responds to damage events using tweens for buttery animations.
Set up the scene tree: CanvasLayer → MarginContainer (anchored top-left, 16px padding) → VBoxContainer → a ProgressBar and a Label. Then attach this script:
extends ProgressBar
@export var player: CharacterBody2D
@export var smooth_speed: float = 0.15
var display_value: float = 100.0
func _ready() -> void:
max_value = 100
value = 100
display_value = 100.0
# Connect to player damage signal
if player and player.has_signal("health_changed"):
player.health_changed.connect(_on_health_changed)
# Style the bar with theme overrides
var fill_style = StyleBoxFlat.new()
fill_style.bg_color = Color(0.2, 0.85, 0.4) # Green fill
fill_style.corner_radius_top_left = 4
fill_style.corner_radius_top_right = 4
fill_style.corner_radius_bottom_left = 4
fill_style.corner_radius_bottom_right = 4
add_theme_stylebox_override("fill", fill_style)
var bg_style = StyleBoxFlat.new()
bg_style.bg_color = Color(0.15, 0.15, 0.15)
bg_style.corner_radius_top_left = 4
bg_style.corner_radius_top_right = 4
bg_style.corner_radius_bottom_left = 4
bg_style.corner_radius_bottom_right = 4
add_theme_stylebox_override("background", bg_style)
func _on_health_changed(new_health: float) -> void:
# Animate smoothly with a tween
var tween = create_tween()
tween.set_ease(Tween.EASE_OUT)
tween.set_trans(Tween.TRANS_CUBIC)
tween.tween_property(self, "value", new_health, smooth_speed)
# Flash red on damage
if new_health < display_value:
modulate = Color(1.0, 0.3, 0.3)
var flash_tween = create_tween()
flash_tween.tween_property(self, "modulate", Color.WHITE, 0.3)
display_value = new_healthThe bar smoothly animates between values and flashes red on damage. Because it listens to a signal, it stays completely decoupled from the player script — if you ever swap characters, the HUD just reconnects to the new signal. Check our animation tutorial for even fancier health bar animations using AnimationPlayer.
Love clean architecture? The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off.
7 — Inventory Grid with Drag-and-Drop
An godot inventory ui combines everything we've covered: a GridContainer for layout, PanelContainer slots for each cell, anchors for positioning the panel on screen, and themes for consistent styling. We covered the data layer and full inventory system in our Godot 4 Inventory System tutorial. Here, we'll focus on the UI grid and drag-and-drop.
Create an InventorySlot scene: a PanelContainer with a TextureRect (Icon) and a Label (Quantity) anchored to the bottom-right. Then handle drag-and-drop with Godot's built-in virtual methods:
class_name InventorySlotUI
extends PanelContainer
signal slot_clicked(index: int)
@onready var icon: TextureRect = $MarginContainer/Icon
@onready var quantity_label: Label = $MarginContainer/QuantityLabel
var slot_index: int = -1
var item_data: Dictionary = {}
func update_display(data: Dictionary) -> void:
item_data = data
var item = data.get("item")
var qty: int = data.get("quantity", 0)
if item == null:
icon.texture = null
quantity_label.text = ""
tooltip_text = ""
else:
icon.texture = item.icon
quantity_label.text = str(qty) if qty > 1 else ""
tooltip_text = item.display_name
# ── Drag-and-drop ────────────────────────────────
func _get_drag_data(_pos: Vector2) -> Variant:
if item_data.get("item") == null:
return null
# Create drag preview
var preview = TextureRect.new()
preview.texture = icon.texture
preview.custom_minimum_size = Vector2(48, 48)
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.modulate = Color(1, 1, 1, 0.75)
set_drag_preview(preview)
return { "from_slot": slot_index, "item": item_data }
func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
return data is Dictionary and data.has("from_slot")
func _drop_data(_pos: Vector2, data: Variant) -> void:
if data is Dictionary and data.has("from_slot"):
var from: int = data["from_slot"]
# Emit signal so the UI controller can swap in the data layer
slot_clicked.emit(from)The _get_drag_data() method creates a visual preview that follows the cursor, _can_drop_data() validates the drop target, and _drop_data() triggers the swap. Data flows in one direction: UI interaction → data mutation → signal → UI refresh. This is the same reactive architecture we use in our signal bus pattern guide.
8 — Settings Screen with Audio & Video Sliders
A polished settings screen is where the container system really shines. The layout is MarginContainer → VBoxContainer → rows of HBoxContainer (label + slider). Each slider controls an audio bus or a display setting. Here's a complete settings manager:
extends Control
@onready var master_slider: HSlider = %MasterSlider
@onready var music_slider: HSlider = %MusicSlider
@onready var sfx_slider: HSlider = %SFXSlider
@onready var fullscreen_toggle: CheckButton = %FullscreenToggle
@onready var vsync_toggle: CheckButton = %VsyncToggle
@onready var resolution_dropdown: OptionButton = %ResolutionDropdown
const RESOLUTIONS := [
Vector2i(1280, 720),
Vector2i(1920, 1080),
Vector2i(2560, 1440),
Vector2i(3840, 2160),
]
func _ready() -> void:
# Initialize audio sliders (range 0–1, step 0.05)
for slider in [master_slider, music_slider, sfx_slider]:
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.05
slider.value = 0.8
master_slider.value_changed.connect(_on_master_changed)
music_slider.value_changed.connect(_on_music_changed)
sfx_slider.value_changed.connect(_on_sfx_changed)
fullscreen_toggle.toggled.connect(_on_fullscreen_toggled)
vsync_toggle.toggled.connect(_on_vsync_toggled)
# Populate resolution dropdown
for res in RESOLUTIONS:
resolution_dropdown.add_item("%dx%d" % [res.x, res.y])
resolution_dropdown.item_selected.connect(_on_resolution_selected)
_load_settings()
func _on_master_changed(val: float) -> void:
_set_bus_volume("Master", val)
func _on_music_changed(val: float) -> void:
_set_bus_volume("Music", val)
func _on_sfx_changed(val: float) -> void:
_set_bus_volume("SFX", val)
func _set_bus_volume(bus_name: String, linear: float) -> void:
var idx = AudioServer.get_bus_index(bus_name)
if idx >= 0:
AudioServer.set_bus_volume_db(idx, linear_to_db(linear))
AudioServer.set_bus_mute(idx, linear < 0.01)
func _on_fullscreen_toggled(enabled: bool) -> void:
if enabled:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
else:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
func _on_vsync_toggled(enabled: bool) -> void:
DisplayServer.window_set_vsync_mode(
DisplayServer.VSYNC_ENABLED if enabled
else DisplayServer.VSYNC_DISABLED
)
func _on_resolution_selected(index: int) -> void:
var res = RESOLUTIONS[index]
DisplayServer.window_set_size(res)
func _load_settings() -> void:
# Load from ConfigFile or JSON — see our save system tutorial
pass
func save_settings() -> void:
# Persist to user://settings.cfg
var config = ConfigFile.new()
config.set_value("audio", "master", master_slider.value)
config.set_value("audio", "music", music_slider.value)
config.set_value("audio", "sfx", sfx_slider.value)
config.set_value("video", "fullscreen", fullscreen_toggle.button_pressed)
config.set_value("video", "vsync", vsync_toggle.button_pressed)
config.save("user://settings.cfg")The audio sliders convert a linear 0–1 value to decibels with linear_to_db(), which gives players a natural-feeling volume curve. The fullscreen and vsync toggles use DisplayServer methods new to Godot 4. For persistent settings, pair this with a ConfigFile or check our save system tutorial for JSON-based approaches.
To organize the scene tree for the settings layout, use this structure:
SettingsScreen (Control — anchored Full Rect)
├── MarginContainer (16px padding all sides)
│ └── VBoxContainer (separation: 12)
│ ├── Label ("Settings" — title)
│ ├── HSeparator
│ ├── HBoxContainer
│ │ ├── Label ("Master Volume")
│ │ └── HSlider (%MasterSlider)
│ ├── HBoxContainer
│ │ ├── Label ("Music")
│ │ └── HSlider (%MusicSlider)
│ ├── HBoxContainer
│ │ ├── Label ("SFX")
│ │ └── HSlider (%SFXSlider)
│ ├── HSeparator
│ ├── HBoxContainer
│ │ ├── Label ("Fullscreen")
│ │ └── CheckButton (%FullscreenToggle)
│ ├── HBoxContainer
│ │ ├── Label ("VSync")
│ │ └── CheckButton (%VsyncToggle)
│ ├── HBoxContainer
│ │ ├── Label ("Resolution")
│ │ └── OptionButton (%ResolutionDropdown)
│ └── Button ("Apply & Close")Each HBoxContainer row has its label with SIZE_EXPAND_FILL on the slider so it stretches to fill remaining space. The % syntax (unique name) lets you reference nodes by name without long paths — a cleaner alternative to $long/path/to/node.
9 — Tips & Best Practices
After building these four screens, here are the patterns that keep Godot UI maintainable at scale:
- Always use a CanvasLayer — UI lives on its own layer, independent of camera transforms. This prevents your HUD from scrolling with the game world.
- One theme per game — Define all colors, fonts, and styleboxes in a single theme resource. Override per-node only when absolutely necessary.
- Prefer containers over manual positioning — Containers handle responsive layout automatically. Reserve anchors for standalone HUD elements.
- Use unique names (%) — The
%NodeNamesyntax saves you from brittle node paths that break when you restructure the scene tree. - Separate data from display — As shown in our inventory system, keep your data layer (resources, dictionaries) separate from the UI layer. Connect them with signals for clean, testable code.
- Animate with tweens — Small animations on show/hide (fade, slide, scale) make UI feel polished. Our tween tutorial covers chaining, easing, and juice effects.
- Test at multiple resolutions — Use the editor's viewport size picker or run the game in windowed mode and resize. Catch layout breaks early.
Level up your GDScript with our free cheat sheet — 20+ copy-paste snippets for signals, tweens, exports, and common patterns.
You now have a complete toolkit for building professional UI in Godot 4. From control nodes and containers to themes, health bars, pause menus, inventory grids, and settings screens — every piece is modular, responsive, and ready to ship.
The godot 4 ui tutorial approach we used keeps layout logic in containers, presentation in themes, and interaction in signals. This separation means you can restyle your entire game by swapping a single theme resource, or rewire your HUD by reconnecting a signal. Fork the code, adapt it to your project, and build something great.
Get the Free GDScript Cheat Sheet + Weekly Godot Tips
Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.
Take your Godot skills further with our 15-minute micro-courses. Each one is focused, practical, and built for indie devs who ship.
Mastering GDScript Signals
Signal buses, typed signals, async patterns — the definitive signals course for Godot 4.
$4.99 · 15 min →State Machines in GDScript
Clean game logic with reusable state machines. Perfect for AI, UI, and game flow.
$4.99 · 15 min →Godot Pro Pack Bundle
All courses at a discount. Signals, shaders, state machines, multiplayer, and more.
$12.99 · Save 50%+ →Jump to Section
Godot 4 Inventory System Tutorial
Build a complete item system with resource-based items, container class, grid UI, drag-and-drop, and JSON save/load.
Godot 4 Tween Tutorial
Animate anything without AnimationPlayer. Chain animations, use easing, and add juice effects to your UI.
Godot 4 State Machine Tutorial
Build a clean, reusable state machine in GDScript. Perfect for menu flow, AI, and game state management.
Free GDScript Cheat Sheet
20+ copy-paste GDScript snippets for signals, tweens, exports, and common patterns.