Tutorial 29: UI Elements: Buttons and Menus
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:
- Clear
isClickedat the start ofUpdate. - If mouse is inside
bounds_→isHovered = true. - If hovered and left button is down →
isPressed = true. - 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;
}