OLED Animation Maker OLED Maker
📘 Arduino OLED Tutorial · SSD1306

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 button…

By Ashish Jul 11, 2026 ~22 min UI, SSD1306, buttons
📖

Clear tutorial

Written for makers — wiring, code, and common mistakes.

🔌

Hardware ready

SSD1306 / SH1106 friendly with pin tables where needed.

💻

Copy-paste code

Working sketches you can upload in Arduino IDE.

✨

Free maker tool

Preview & export more animations at oledanimationmaker.com.

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)
PartArduino Uno
OLED VCC / GND5V / GND
OLED SDA / SCLA4 / A5
BTN_UPD2 → GND when pressed
BTN_DOWND3 → GND when pressed
BTN_SELD4 → 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)

  1. No debounce. One physical press becomes five menu jumps. Fix: ignore repeats for ~180 ms after an edge.
  2. Redraw every loop forever. Flicker and wasted CPU. Fix: redraw only when selection or screen changes.
  3. Blocking delay() everywhere. Buttons feel dead during animations. Fix: millis() timing.
  4. 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:

TimerStarting pointWhat goes wrong nearby
Debounce stable window30 msUnder 15 ms double-steps; over 60 ms feels mushy
Delay before key repeat400 ms held LOWShorter than ~250 ms repeats on the initial click
Repeat period120 ms60 ms flies past an 8-item list; 200 ms feels stuck
Long-press Back, if you add it600 ms on SelectMust 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_PULLUP in setup() 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 a String log line and blew the 2 KB SRAM that already stores the 1024-byte framebuffer. Print with F().
  • 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 - lastChange is 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 →