Free Tutorial · UI Design

Godot 4 UI Tutorial — Build Responsive Menus, HUDs
& Inventory Screens

SScriptSnap/March 2026/15 min read

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.

Tutorial

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 MarginContainerVBoxContainer → 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:

anchor_example.gd
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 width

For 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.

Take the course — $4.99 →
📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime

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”.

theme_setup.gd
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: CanvasLayerMarginContainer (anchored top-left, 16px padding) → VBoxContainer → a ProgressBar and a Label. Then attach this script:

health_bar.gd
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_health

The 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.

6 — Building a Pause Menu

A godot menu tutorial wouldn't be complete without a pause screen. The scene tree is: CanvasLayerColorRect (full-screen dark overlay) → CenterContainerVBoxContainer → buttons for Resume, Settings, and Quit. Here's the controller script:

pause_menu.gd
extends CanvasLayer

@onready var overlay: ColorRect = $Overlay
@onready var resume_btn: Button = $Overlay/Center/VBox/ResumeButton
@onready var settings_btn: Button = $Overlay/Center/VBox/SettingsButton
@onready var quit_btn: Button = $Overlay/Center/VBox/QuitButton

func _ready() -> void:
    visible = false
    process_mode = Node.PROCESS_MODE_ALWAYS  # Runs even when paused

    resume_btn.pressed.connect(_on_resume)
    settings_btn.pressed.connect(_on_settings)
    quit_btn.pressed.connect(_on_quit)

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("pause"):
        toggle_pause()
        get_viewport().set_input_as_handled()

func toggle_pause() -> void:
    var is_paused = not get_tree().paused
    get_tree().paused = is_paused
    visible = is_paused

    if is_paused:
        _animate_open()
    else:
        _animate_close()

func _animate_open() -> void:
    overlay.modulate = Color(1, 1, 1, 0)
    var tween = create_tween()
    tween.set_ease(Tween.EASE_OUT)
    tween.tween_property(overlay, "modulate:a", 1.0, 0.2)

func _animate_close() -> void:
    var tween = create_tween()
    tween.tween_property(overlay, "modulate:a", 0.0, 0.15)
    tween.tween_callback(func(): visible = false)

func _on_resume() -> void:
    toggle_pause()

func _on_settings() -> void:
    # Navigate to settings screen or show settings panel
    pass

func _on_quit() -> void:
    get_tree().quit()

The critical detail is process_mode = PROCESS_MODE_ALWAYS. Without it, the pause menu itself would freeze when the tree is paused and the player couldn't unpause. The _unhandled_input()callback ensures the menu catches the pause action even if other nodes are paused.

For more complex menu flows (main menu → settings → keybindings → back), consider using a state machine to manage which sub-menu is currently active. Each state shows/hides a specific container, making navigation clean and debuggable.

Love clean architecture? The Godot Pro Pack bundles signals, state machines, shaders, and 3 more courses at 50%+ off.

Get the Pro Pack — $12.99 →

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:

inventory_slot_ui.gd
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 MarginContainerVBoxContainer → rows of HBoxContainer (label + slider). Each slider controls an audio bus or a display setting. Here's a complete settings manager:

settings_screen.gd
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:

Scene Tree — Settings Panel
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 %NodeName syntax 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.

Get Cheat Sheet →
EOF

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.

Free download
+ weekly tips

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.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.