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.
The highlight skips two rows when I hold Up
The edge detector already returns one event per press. A second “repeat while held” timer that also moves the cursor, with no gap after the first edge, fires twice in the first tenth of a second. Start repeats only after about 400 ms of a steady LOW, then every 120 ms.
Select works on the main list and does nothing on the confirm screen
The confirm screen has its own cursor count. Reusing the main-menu index without clamping leaves
cursor at 2 when the dialog only has Yes and No. Set cursor = 0 on the way in.
The same sketch feels fine on Uno and jittery on ESP32
ESP32 loop() can run much faster, so a 25 ms debounce window still works, but a button
wired to a pin that is also a strap or an LED will bounce in software forever. Keep buttons on ordinary
GPIOs, OLED SDA on GPIO21, SCL on GPIO22, address 0x3C.
A scrolling list and a confirm screen
Three rows fit under the title line of a 128×64 panel. A settings product grows past that on the first
afternoon: brightness, timeout, units, invert, about, and a destructive “erase log.” Stuffing every
label into y = 16 + i * 14 pushes the last rows off the glass. Keep the draw loop, and add
a window.
Store top as the index of the first visible row. Draw only three items,
items[top + row], and let cursor be the absolute index in the full list. When
Up would move the cursor above top, decrement both. When Down would move past
top + 2, increment top and stop at count - 3. The highlight
rectangle stays on screen; the labels slide under it. Reset top and cursor
whenever you enter a screen, because a Settings list of 6 and a main list of 3 cannot share a leftover
index.
const uint8_t VISIBLE = 3;
void nudge(int8_t dir, uint8_t count) {
int next = cursor + dir;
if (next < 0 || next >= count) return; // clamp, do not wrap, on long lists
cursor = next;
if (cursor < top) top = cursor;
if (cursor > top + VISIBLE - 1) top = cursor - (VISIBLE - 1);
dirty = true;
}
Wrapping with % is friendly for a three-item demo and hostile for eight items: one extra
Down click teleports the user from “Erase” back to “Brightness” with no visual warning. Clamp at the
ends, and paint a tiny arrow in the right margin when top > 0 or when more rows exist
below the window. That arrow is four pixels. It prevents the “the menu ate my item” bug reports.
Second screen: confirm before erase
Destructive rows should not fire on the same Select that merely enters edit mode. Add
SCR_CONFIRM. From the erase row, Select sets a function pointer or a small enum
(PENDING_ERASE), forces cursor = 0, and draws two choices, “Cancel” and
“Erase,” plus one line that names the action. Select on Cancel returns to Settings with the cursor
restored to the erase row. Select on Erase performs the work, then returns. Backing out must be the
default highlight. People mash Select when the UI feels laggy; the safe row has to be the one under
their thumb.
Hold-to-repeat belongs on Up and Down only. Select stays a single edge so a long press cannot confirm a dialog the user has not read. A practical set of constants, measured against cheap tactile switches rather than a datasheet:
| Timer | Starting point | What goes wrong nearby |
|---|---|---|
| Debounce stable window | 30 ms | Under 15 ms double-steps; over 60 ms feels mushy |
| Delay before key repeat | 400 ms held LOW | Shorter than ~250 ms repeats on the initial click |
| Repeat period | 120 ms | 60 ms flies past an 8-item list; 200 ms feels stuck |
| Long-press Back, if you add it | 600 ms on Select | Must be ignored while a confirm dialog is open |
Implement repeat without delay(). Remember the timestamp of the last accepted move. If the
pin is still LOW and now - lastMove exceeds the repeat period, synthesize another Up or
Down and set dirty. The OLED still redraws once per accepted move. A full-width inverted
bar is 128×12 white pixels; that is fine, and it is also a lot of lit area if you later care about
battery. A > marker in column 0 keeps the same navigation code and leaves the row dark.
Bench failures that look like logic bugs
-
Cursor runs without a finger on the button. The pin was left floating.
INPUT_PULLUPinsetup()is mandatory on Uno D2, D3, and D4. A wire to 5V instead of GND inverts the sense: the menu scrolls on its own and the press stops it. -
Opening confirm blanks the panel. You drew the dialog and returned without
display(), or the erase handler built aStringlog line and blew the 2 KB SRAM that already stores the 1024-byte framebuffer. Print withF(). -
Values edit one step past the user’s click. Both the edge event and the repeat timer
accepted the same millisecond. Ignore repeats until
now - lastChangeis past the 400 ms arming delay. -
ESP32 build compiles and the highlight never moves. Buttons on GPIO0 or GPIO15 fight
boot mode. Move them. Keep the OLED on GPIO21 and GPIO22 at
0x3C.
When this list is the front end of a logger, the Run screen from the first sketch becomes page one and
the scrolling settings become page two. Share the button helper. Do not share cursor
across screens without an explicit reset. That single assignment prevents the confirm dialog from
highlighting a row that does not exist.
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 →