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
| Pattern | Flash | RAM |
|---|---|---|
| Region split + clipping | 0.9 KB | 8 B |
| Inertial menu | 1.6 KB | 24 B |
| Toast | 0.7 KB | 20 B |
| Sparkline | 1.2 KB | 128 B/instance |
| Transitions | 0.4 KB | 0 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.
Keep reading
More from the bench
Engineering This Website
How vivaanshah.tech is built: Astro 5 static output on Cloudflare Pages, a dependency-free motion engine, seeded generative circuit art, and a CSP with no unsafe-inline.
Read postTaming Stepper Motor Resonance with TMC2209s
Why my robotic arm sang like a violin at 400 mm/min, and how StealthChop, microstepping and a firmware notch filter fixed it.
Read post