Tutorial 29: UI Elements: Buttons and Menus

2D Techniques  ·  Intermediate

Button Class

A button needs three things: where it is, what state it is in, and what it looks like. Track hover and press as persistent booleans, but make click a one-frame flag so callers don't need to debounce it themselves:

class Button {
public:
    Button(Rectangle bounds, std::string label)
        : bounds_(bounds), label_(std::move(label)) {}

    Rectangle   bounds_;
    std::string label_;
    bool        isHovered{ false };
    bool        isPressed{ false };  // held down this frame
    bool        isClicked{ false };  // true for exactly one frame on release
};

The state machine per frame:

  1. Clear isClicked at the start of Update.
  2. If mouse is inside bounds_isHovered = true.
  3. If hovered and left button is down → isPressed = true.
  4. If previously pressed and now released while still hovered → isClicked = true.

Requiring that the mouse is still inside on release prevents accidental activations when the user drags away from the button.

Mouse Hit Testing

Rectangle::Contains(Point) does the hit test. The important detail is detecting the rising edge of the mouse button — the transition from Released to Pressed — not just whether the button is currently held. Store the previous frame's MouseState to compare:

void Button::Update(const MouseState& cur, const MouseState& prev) {
    isClicked = false;

    Point mousePos{ cur.X, cur.Y };
    isHovered = bounds_.Contains(mousePos);

    bool heldNow  = (cur.LeftButton  == ButtonState::Pressed);
    bool heldPrev = (prev.LeftButton == ButtonState::Pressed);

    isPressed = isHovered && heldNow;

    // Click fires on release while still hovering
    if (isHovered && heldPrev && !heldNow)
        isClicked = true;
}

Text Buttons

Draw a coloured background rectangle using a 1×1 white texture scaled to the button size, then draw the label centred using SpriteFont::MeasureString:

void Button::Draw(SpriteBatch& sb, SpriteFont& font, Texture2D& pixel) {
    // Background colour based on state
    Color bg = Color(60, 60, 80, 220);
    if (isPressed)      bg = Color(30, 30, 50, 255);
    else if (isHovered) bg = Color(90, 90, 130, 240);

    // Draw background quad
    sb.Draw(pixel, bounds_, bg);

    // Measure text and centre it
    Vector2 textSize = font.MeasureString(label_);
    Vector2 textPos{
        bounds_.X + (bounds_.Width  - textSize.X) * 0.5f,
        bounds_.Y + (bounds_.Height - textSize.Y) * 0.5f
    };

    Color textCol = isPressed ? Color(180, 180, 180, 255)
                  : isHovered ? Color(255, 230, 100, 255)
                  :             Color::White;

    font.DrawString(sb, label_, textPos, textCol);
}

Image Buttons

Replace the background colour with a sprite sheet source rectangle. Store a Texture2D* and three Rectangle source rects (normal, hover, pressed) — this is the standard ninepatch-or-atlas approach used in XNA games:

class ImageButton {
public:
    Texture2D* texture;
    Rectangle  bounds;
    Rectangle  srcNormal;
    Rectangle  srcHovered;
    Rectangle  srcPressed;
    bool       isHovered{ false };
    bool       isClicked{ false };

    void Draw(SpriteBatch& sb) {
        Rectangle src = isPressed  ? srcPressed
                      : isHovered  ? srcHovered
                      :              srcNormal;
        sb.Draw(*texture, bounds, &src, Color::White);
    }
};

Simple Menu System

A menu owns a list of buttons and routes input through them. It also tracks a focused index for keyboard navigation:

class MainMenu {
public:
    enum class Action { None, NewGame, Options, Quit };

    MainMenu() {
        int cx = 400, y = 220, w = 220, h = 50, gap = 70;
        buttons_.emplace_back(Rectangle(cx - w/2, y,         w, h), "New Game");
        buttons_.emplace_back(Rectangle(cx - w/2, y + gap,   w, h), "Options");
        buttons_.emplace_back(Rectangle(cx - w/2, y + gap*2, w, h), "Quit");
    }

    Action HandleInput(const MouseState& cur,   const MouseState& prev,
                       const KeyboardState& kb,  const KeyboardState& prevKb) {
        // Mouse
        for (auto& btn : buttons_)
            btn.Update(cur, prev);

        if (buttons_[0].isClicked) return Action::NewGame;
        if (buttons_[1].isClicked) return Action::Options;
        if (buttons_[2].isClicked) return Action::Quit;

        // Keyboard navigation
        bool downNow  = kb.IsKeyDown(Keys::Down);
        bool upNow    = kb.IsKeyDown(Keys::Up);
        bool downPrev = prevKb.IsKeyDown(Keys::Down);
        bool upPrev   = prevKb.IsKeyDown(Keys::Up);

        if (downNow && !downPrev)
            focused_ = (focused_ + 1) % static_cast<int>(buttons_.size());
        if (upNow && !upPrev)
            focused_ = (focused_ - 1 + static_cast<int>(buttons_.size()))
                       % static_cast<int>(buttons_.size());

        bool activateNow  = kb.IsKeyDown(Keys::Enter) || kb.IsKeyDown(Keys::Space);
        bool activatePrev = prevKb.IsKeyDown(Keys::Enter) || prevKb.IsKeyDown(Keys::Space);
        if (activateNow && !activatePrev) {
            if (focused_ == 0) return Action::NewGame;
            if (focused_ == 1) return Action::Options;
            if (focused_ == 2) return Action::Quit;
        }

        return Action::None;
    }

    void Draw(SpriteBatch& sb, SpriteFont& font, Texture2D& pixel) {
        for (int i = 0; i < static_cast<int>(buttons_.size()); ++i) {
            // Override hover for keyboard-focused button
            bool savedHover = buttons_[i].isHovered;
            if (i == focused_) buttons_[i].isHovered = true;
            buttons_[i].Draw(sb, font, pixel);
            buttons_[i].isHovered = savedHover;
        }
    }

private:
    std::vector<Button> buttons_;
    int                 focused_{ 0 };
};

Focus and Keyboard Navigation

The pattern above handles the common case. For GamePad navigation, map GamePadState::DPad.Down and DPad.Up to the same focused-index logic, and Buttons.A to activation:

auto gp = GamePad::GetState(PlayerIndex::One);
if (gp.IsConnected()) {
    if (gp.DPad.Down == ButtonState::Pressed &&
        prevGp.DPad.Down == ButtonState::Released)
        focused_ = (focused_ + 1) % buttonCount;

    if (gp.Buttons.A == ButtonState::Pressed &&
        prevGp.Buttons.A == ButtonState::Released)
        // activate focused button
        ;
}

Always store the previous GamePadState just like the previous KeyboardState; button state transitions are the correct way to detect single presses regardless of input device.

Draw UI in its own SpriteBatch::Begin / End block after the game world. Use SpriteSortMode::Deferred and BlendState::AlphaBlend for the UI batch, optionally with a SamplerState that uses point filtering for crisp pixel-art buttons. Never mix UI and world sprites in the same batch if they require different blend states.

Full Example

A main menu with three buttons, navigable by mouse, keyboard arrows, and Enter/Space.

#include <memory>
#include <string>
#include <vector>

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Mouse.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteFont.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

// ---- Button ------------------------------------------------
class Button {
public:
    Button(Rectangle b, std::string lbl) : bounds_(b), label_(std::move(lbl)) {}

    void Update(const MouseState& cur, const MouseState& prev) {
        isClicked = false;
        Point mp{ cur.X, cur.Y };
        isHovered = bounds_.Contains(mp);
        bool heldNow  = (cur.LeftButton  == ButtonState::Pressed);
        bool heldPrev = (prev.LeftButton == ButtonState::Pressed);
        isPressed = isHovered && heldNow;
        if (isHovered && heldPrev && !heldNow) isClicked = true;
    }

    void Draw(SpriteBatch& sb, SpriteFont& font, Texture2D& pixel,
              bool forceHover = false) {
        bool hov = isHovered || forceHover;
        Color bg  = isPressed  ? Color(20,  20,  40,  255)
                  : hov        ? Color(80,  80, 140, 240)
                  :              Color(50,  50,  80, 210);
        sb.Draw(pixel, bounds_, bg);

        Vector2 sz = font.MeasureString(label_);
        Vector2 tp{ bounds_.X + (bounds_.Width  - sz.X) * 0.5f,
                    bounds_.Y + (bounds_.Height - sz.Y) * 0.5f };
        Color tc = isPressed  ? Color(160, 160, 160, 255)
                 : hov        ? Color(255, 230,  80, 255)
                 :              Color::White;
        font.DrawString(sb, label_, tp, tc);
    }

    Rectangle   bounds_;
    std::string label_;
    bool        isHovered{ false };
    bool        isPressed{ false };
    bool        isClicked{ false };
};

// ---- MainMenu ----------------------------------------------
class MainMenu {
public:
    enum class Action { None, NewGame, Options, Quit };

    MainMenu() {
        int cx = 400, y = 200, w = 240, h = 54, gap = 74;
        buttons_.emplace_back(Rectangle(cx - w/2, y,         w, h), "New Game");
        buttons_.emplace_back(Rectangle(cx - w/2, y + gap,   w, h), "Options");
        buttons_.emplace_back(Rectangle(cx - w/2, y + gap*2, w, h), "Quit");
    }

    Action Update(const MouseState& cur,  const MouseState& prev,
                  const KeyboardState& kb, const KeyboardState& pkb) {
        for (auto& b : buttons_) b.Update(cur, prev);
        if (buttons_[0].isClicked) return Action::NewGame;
        if (buttons_[1].isClicked) return Action::Options;
        if (buttons_[2].isClicked) return Action::Quit;

        auto edgeDown = [&](Keys k){ return kb.IsKeyDown(k) && !pkb.IsKeyDown(k); };
        int  n = static_cast<int>(buttons_.size());
        if (edgeDown(Keys::Down)) focused_ = (focused_ + 1) % n;
        if (edgeDown(Keys::Up))   focused_ = (focused_ - 1 + n) % n;
        if (edgeDown(Keys::Enter) || edgeDown(Keys::Space)) {
            if (focused_ == 0) return Action::NewGame;
            if (focused_ == 1) return Action::Options;
            if (focused_ == 2) return Action::Quit;
        }
        return Action::None;
    }

    void Draw(SpriteBatch& sb, SpriteFont& font, Texture2D& pixel) {
        for (int i = 0; i < static_cast<int>(buttons_.size()); ++i)
            buttons_[i].Draw(sb, font, pixel, i == focused_);
    }

private:
    std::vector<Button> buttons_;
    int                 focused_{ 0 };
};

// ---- Game --------------------------------------------------
class MenuGame final : public Game {
public:
    MenuGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        font_   = std::make_unique<SpriteFont>("assets/fonts/menu.spritefont",
                      getGraphicsDeviceProperty());
        pixel_  = std::make_unique<Texture2D>(getGraphicsDeviceProperty(), 1, 1);
        Color w{ 255, 255, 255, 255 };
        pixel_->SetData(&w, 1);
    }

    void Update(GameTime&) override {
        auto  kb  = Keyboard::GetState();
        auto  ms  = Mouse::GetState();

        MainMenu::Action act = menu_.Update(ms, prevMs_, kb, prevKb_);
        prevMs_ = ms;
        prevKb_ = kb;

        if (act == MainMenu::Action::NewGame) { /* start game */ }
        if (act == MainMenu::Action::Quit)    { Exit(); }
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(15, 15, 25, 255));

        spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend);
        menu_.Draw(*spriteBatch_, *font_, *pixel_);
        spriteBatch_->End();

        gd.Present();
    }

private:
    GraphicsDeviceManager        graphics_;
    std::unique_ptr<SpriteBatch> spriteBatch_;
    std::unique_ptr<SpriteFont>  font_;
    std::unique_ptr<Texture2D>   pixel_;
    MainMenu                     menu_;
    MouseState                   prevMs_;
    KeyboardState                prevKb_;
};

int main() {
    MenuGame game;
    game.Run();
    return 0;
}