Blog Tutorial

Five UI Patterns for 128x64 OLEDs

Every project I build ends up with the same $2 display on it: a 128x64 SSD1306 over I2C. After rewriting the same UI code for the fourth time, I pulled the patterns into a small library. Here are the five I use constantly, with the numbers that matter: the whole set compiles to about 7.4KB on an ATmega328 and the framebuffer eats 1KB of RAM — half of what the 328 has, so budget accordingly.

1. Status bar + content split

Reserve the top 10 pixels for a status bar (title, battery, RSSI) and treat the remaining 128x54 as the content region. The trick is enforcing it in code, not by convention:

struct Region { uint8_t x, y, w, h; };
constexpr Region kStatusBar{0, 0, 128, 10};
constexpr Region kContent{0, 10, 128, 54};

void drawText(const Region& r, uint8_t x, uint8_t y, const char* s) {
  // clips against r, so a content widget can never scribble on the bar
}

Once drawing is region-clipped, screens stop stepping on each other and you can redraw the status bar on its own 1Hz timer instead of every frame.

2. Rotary menu with inertia

A raw encoder feels dead on long lists. I track detents per 50ms window and multiply the step size when you spin fast:

int8_t detents = readEncoderDelta();
uint8_t speed  = abs(detents);              // detents this window
int16_t step   = detents * (speed >= 3 ? 4 : 1);
cursor = constrain(cursor + step, 0, itemCount - 1);

Threshold of 3 detents per window, 4x multiplier. I tried a proper exponential curve first — it felt floaty and users overshot constantly. The dumb two-speed version is better.

3. Toast notifications

Events (“Saved”, “WiFi lost”) shouldn’t need their own screen. A toast is a 100x14 inverted box centered near the bottom, drawn after the active screen every frame, with a millis() deadline. One static slot, no queue — a new toast replaces the old one. In practice that’s what you want anyway; a queue just shows you stale news.

4. Sparkline history

For any sensor value, a 64-sample ring buffer plus a 64x16 plot beats a big number. Autoscale to the window min/max, but clamp the range to a minimum span so noise on a flat signal doesn’t render as a dramatic mountain range:

int16_t lo = *min_element(buf, buf + 64);
int16_t hi = *max_element(buf, buf + 64);
if (hi - lo < kMinSpan) { hi = lo + kMinSpan; }  // flat line stays flat

Cost: 128 bytes of RAM per sparkline. Worth it every time.

5. Screen transitions under 8KB

Full slide animations mean compositing two framebuffers — 2KB of RAM I don’t have. Instead I fake it: scroll the old buffer out using the SSD1306’s hardware horizontal scroll for 4 frames, then blit the new screen. It reads as a transition and costs zero extra RAM and ~120 bytes of code.

Budget summary

PatternFlashRAM
Region split + clipping0.9 KB8 B
Inertial menu1.6 KB24 B
Toast0.7 KB20 B
Sparkline1.2 KB128 B/instance
Transitions0.4 KB0 B

None of this is clever individually. The value is that they compose: every screen in the deauther, the weather node, and the arm pendant is now the same five parts arranged differently, and a new screen takes minutes instead of an evening.