OLED Animation Maker OLED Maker

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.

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 →