Arduino OLED Menu with Buttons — Build a Real Navigation UI
Most OLED tutorials stop at “Hello World.” Real projects need a menu: pick a mode, change a setting, go back, open a submenu. This article is the pattern I use on classroom robots and small instruments — three buttons, one 128×64 SSD1306, no fancy touchscreen.
You will get: wiring, debounce that does not lie, a highlight bar, nested screens, and a settings page where Up/Down change a number. Everything is plain Adafruit GFX. No images required — the screen is text and rectangles.
What you will build
- Main menu: Start / Settings / About
- Settings submenu: brightness proxy (contrast text), delay ms, back
- Highlight moves with Up/Down; Select enters or confirms
- Non-blocking button reads so animations can run later on the same board
Parts and wiring
- Arduino Uno or Nano
- I2C SSD1306 128×64
- Three momentary pushbuttons (to GND, internal pull-ups)
| Part | Arduino Uno |
|---|---|
| OLED VCC / GND | 5V / GND |
| OLED SDA / SCL | A4 / A5 |
| BTN_UP | D2 → GND when pressed |
| BTN_DOWN | D3 → GND when pressed |
| BTN_SEL | D4 → GND when pressed |
Use INPUT_PULLUP. Pressed = LOW. That saves external resistors and is how most maker
kits are wired already.
Why menus break (and how we avoid it)
- No debounce. One physical press becomes five menu jumps. Fix: ignore repeats for ~180 ms after an edge.
- Redraw every loop forever. Flicker and wasted CPU. Fix: redraw only when selection or screen changes.
- Blocking
delay()everywhere. Buttons feel dead during animations. Fix:millis()timing. - Huge string tables in SRAM. On Uno put labels in
F()/ PROGMEM flash strings.
Screen model
Keep a small state machine. Do not try to encode the whole UI as one giant if tree without
names — you will hate yourself next week.
enum Screen : uint8_t {
SCR_MAIN = 0,
SCR_SETTINGS,
SCR_ABOUT,
SCR_RUN
};
Screen screen = SCR_MAIN;
int8_t cursor = 0; // highlighted row
bool dirty = true; // needs redraw
// Settings values
int delayMs = 80;
int contrastLevel = 2; // 0..3 mapped to text only in this demo
Button helper (edge + debounce)
const uint8_t PIN_UP = 2;
const uint8_t PIN_DN = 3;
const uint8_t PIN_OK = 4;
struct Btn {
uint8_t pin;
bool lastStable;
bool lastRaw;
unsigned long lastChange;
};
Btn bUp{PIN_UP, true, true, 0};
Btn bDn{PIN_DN, true, true, 0};
Btn bOk{PIN_OK, true, true, 0};
// returns true once per press (active low)
bool pressed(Btn &b) {
bool raw = digitalRead(b.pin); // HIGH = not pressed
unsigned long now = millis();
if (raw != b.lastRaw) {
b.lastChange = now;
b.lastRaw = raw;
}
if ((now - b.lastChange) > 25) {
if (raw != b.lastStable) {
b.lastStable = raw;
if (raw == LOW) return true; // just pressed
}
}
return false;
}
25 ms is enough for cheap tactile switches. If you still get doubles, raise it to 40. Do not put
delay(200) after every press — that is how menus feel laggy.
Drawing the main menu
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
const char* mainItems[] = { "Start", "Settings", "About" };
const uint8_t MAIN_COUNT = 3;
void drawMain() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("MAIN MENU"));
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
for (uint8_t i = 0; i < MAIN_COUNT; i++) {
int y = 16 + i * 14;
if (i == cursor) {
display.fillRect(0, y - 2, 128, 12, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else {
display.setTextColor(SSD1306_WHITE);
}
display.setCursor(6, y);
display.print(mainItems[i]);
}
display.display();
}
Inverted row = selected. Students understand this instantly. You can swap the fill for a >
prefix if you prefer less flashing.
Settings screen (edit a value)
On Settings, cursor picks a row. When the row is a value, Up/Down change the number instead of moving the cursor — or you enter “edit mode” with Select. Below is the simpler classroom version: cursor moves between rows; on a value row, Left is not needed — Up/Down adjust while holding Select, or we use a dedicated edit flag.
const char* setItems[] = { "Delay ms", "Level", "Back" };
const uint8_t SET_COUNT = 3;
bool editing = false;
void drawSettings() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(editing ? F("SETTINGS *") : F("SETTINGS"));
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
for (uint8_t i = 0; i < SET_COUNT; i++) {
int y = 16 + i * 14;
bool hi = (i == cursor);
if (hi) {
display.fillRect(0, y - 2, 128, 12, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else {
display.setTextColor(SSD1306_WHITE);
}
display.setCursor(4, y);
display.print(setItems[i]);
if (i == 0) {
display.setCursor(80, y);
display.print(delayMs);
} else if (i == 1) {
display.setCursor(80, y);
display.print(contrastLevel);
}
}
display.display();
}
Full sketch (copy-paste)
This is the complete program. Upload it, open the Serial Monitor only if you want debug — UI is on the OLED.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
enum Screen : uint8_t { SCR_MAIN, SCR_SETTINGS, SCR_ABOUT, SCR_RUN };
Screen screen = SCR_MAIN;
int8_t cursor = 0;
bool dirty = true;
bool editing = false;
int delayMs = 80;
int contrastLevel = 2;
const uint8_t PIN_UP = 2, PIN_DN = 3, PIN_OK = 4;
struct Btn {
uint8_t pin;
bool lastStable;
bool lastRaw;
unsigned long lastChange;
};
Btn bUp{PIN_UP, true, true, 0};
Btn bDn{PIN_DN, true, true, 0};
Btn bOk{PIN_OK, true, true, 0};
bool pressed(Btn &b) {
bool raw = digitalRead(b.pin);
unsigned long now = millis();
if (raw != b.lastRaw) { b.lastChange = now; b.lastRaw = raw; }
if ((now - b.lastChange) > 30) {
if (raw != b.lastStable) {
b.lastStable = raw;
if (raw == LOW) return true;
}
}
return false;
}
void drawMain() {
const char* items[] = {"Start", "Settings", "About"};
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("MAIN MENU"));
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
for (uint8_t i = 0; i < 3; i++) {
int y = 18 + i * 14;
if (i == cursor) {
display.fillRect(0, y - 2, 128, 12, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else display.setTextColor(SSD1306_WHITE);
display.setCursor(8, y);
display.print(items[i]);
}
display.display();
}
void drawSettings() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(editing ? F("SETTINGS [edit]") : F("SETTINGS"));
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
const char* items[] = {"Delay ms", "Level", "Back"};
for (uint8_t i = 0; i < 3; i++) {
int y = 18 + i * 14;
if (i == cursor) {
display.fillRect(0, y - 2, 128, 12, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else display.setTextColor(SSD1306_WHITE);
display.setCursor(4, y);
display.print(items[i]);
if (i == 0) { display.setCursor(78, y); display.print(delayMs); }
if (i == 1) { display.setCursor(78, y); display.print(contrastLevel); }
}
display.display();
}
void drawAbout() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("ABOUT"));
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
display.setCursor(0, 20);
display.println(F("OLED menu demo"));
display.println(F("3 buttons + SSD1306"));
display.println(F("Select = back"));
display.display();
}
void drawRun() {
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(10, 10);
display.println(F("RUNNING"));
display.setTextSize(1);
display.setCursor(10, 40);
display.print(F("delay="));
display.print(delayMs);
display.println(F(" Sel=stop"));
display.display();
}
void setup() {
pinMode(PIN_UP, INPUT_PULLUP);
pinMode(PIN_DN, INPUT_PULLUP);
pinMode(PIN_OK, INPUT_PULLUP);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) for (;;);
display.clearDisplay();
display.display();
}
void loop() {
bool up = pressed(bUp);
bool dn = pressed(bDn);
bool ok = pressed(bOk);
if (screen == SCR_MAIN) {
if (up) { cursor = (cursor + 2) % 3; dirty = true; }
if (dn) { cursor = (cursor + 1) % 3; dirty = true; }
if (ok) {
if (cursor == 0) { screen = SCR_RUN; }
else if (cursor == 1) { screen = SCR_SETTINGS; cursor = 0; editing = false; }
else { screen = SCR_ABOUT; }
dirty = true;
}
if (dirty) { drawMain(); dirty = false; }
}
else if (screen == SCR_SETTINGS) {
if (!editing) {
if (up) { cursor = (cursor + 2) % 3; dirty = true; }
if (dn) { cursor = (cursor + 1) % 3; dirty = true; }
if (ok) {
if (cursor == 2) { screen = SCR_MAIN; cursor = 1; }
else { editing = true; }
dirty = true;
}
} else {
if (up) {
if (cursor == 0) delayMs = constrain(delayMs + 10, 20, 500);
if (cursor == 1) contrastLevel = constrain(contrastLevel + 1, 0, 3);
dirty = true;
}
if (dn) {
if (cursor == 0) delayMs = constrain(delayMs - 10, 20, 500);
if (cursor == 1) contrastLevel = constrain(contrastLevel - 1, 0, 3);
dirty = true;
}
if (ok) { editing = false; dirty = true; }
}
if (dirty) { drawSettings(); dirty = false; }
}
else if (screen == SCR_ABOUT) {
if (ok) { screen = SCR_MAIN; cursor = 2; dirty = true; }
if (dirty) { drawAbout(); dirty = false; }
}
else if (screen == SCR_RUN) {
if (ok) { screen = SCR_MAIN; cursor = 0; dirty = true; }
if (dirty) { drawRun(); dirty = false; }
// place animation / sensor work here using delayMs
}
}
How to extend this for a product
- Save settings to EEPROM when leaving Settings so power cycles keep delayMs.
- Long-press Select for back — useful when you only have two buttons.
- Rotary encoder instead of Up/Down — same cursor math, different input driver.
- Icons — 8×8 PROGMEM bitmaps beside labels; keep them tiny on Uno.
- Timeout — return to main if no button for 30 s (sleep UI).
SRAM note for Uno
One 128×64 Adafruit buffer is 1 KB. Menu strings in RAM add up. Prefer
display.println(F("text")) and avoid String concatenation. If you add a second
full-screen buffer for animation frames, you will crash. See
Uno memory limits.
Debugging tips from the bench
- If the highlight jumps two steps, debounce is too short or you are polling twice per press.
- If Select does nothing, confirm the pin is pulled up and wiring is to GND (not to 5V).
- If the OLED blanks only after opening Settings, you may have crashed SRAM — simplify draw code.
- Test buttons with Serial prints before attaching menu logic.
FAQ
Can I do this with one button?
Yes — short press = next item, long press = select. Harder for beginners; three buttons are clearer for teaching.
Will this work on ESP32?
Yes. Change SDA/SCL if needed (often 21/22) and keep the same menu logic. ESP32 has more RAM for richer UI.
How do I add icons next to menu items?
drawBitmap(0, y, icon, 8, 8, color) then print text at x=12. Generate icons in the OLED animation maker as static frames.
Related
Need icons for menu rows?
Draw 8×8 or 16×16 glyphs in the free tool and paste PROGMEM arrays into this sketch.
Open oledanimationmaker.com →