What if your macropad could actually react to you?
This is a 6-key DIY macropad built on the XIAO ESP32-S3 with a 0.9" OLED display running animated eyes that blink, look around, and change expression based on exactly which key you press. It works as a native USB HID keyboard β no Python script, no drivers, just plug in and it works like a normal keyboard. Except this one has a personality.
The case is fully 3D printed in Fusion 360. The icon keycaps β Discord, VS Code, Spotify, and media controls β are printed in white PLA with icons. Simple, clean, and surprisingly sharp looking in person.
The best part? No QMK, no VIAL, no configuration software needed on your PC. Flash once, done forever.
Scroll through the steps below β the build is simpler than it looks, and the result is something that genuinely feels like a finished product sitting on your desk. π
SuppliesElectronic Components Required:
- Seeed Studio XIAO ESP32-S3
- 0.9" OLED display
- Kailh mechanical switches
- USB-C Cable
- 3D Printed body & keycaps (see step 1)
The entire macropad body was designed in Autodesk Fusion 360 and split into two separate parts β the main body and the lid β so both pieces can be printed flat on the bed without any supports needed. The embedded Fusion 360 file is linked below for you to download, modify, or remix as you like.
Both parts were printed in black PLA filament which gives that clean, matte, almost commercial-product finish you see in the final build. Print settings are straightforward β 15% infill is more than enough structural rigidity for a macropad since it's not a load-bearing part, just sitting on your desk. Standard 0.2mm layer height works perfectly fine here. No supports required.
For the keycaps, I didn't design these from scratch β full credit goes to Camilla on Printables for the keycap design. You can download them here: Macropad with Keycaps by Camilla. If you want sharper, cleaner keycap surfaces, Camilla recommends printing at 0.07mm layer height with ironing enabled on all top surfaces β it makes a noticeable difference. I printed mine in white PLA and then filled in the icons using a black permanent marker β simple jugaad that actually looks surprisingly clean in person.
Print the body, print the lid, print the six keycaps, and you're ready for the build. β
Step 2: Elevate Your Electronic Projects - JLCMCJLCMC is your one-stop shop for all electronic manufacturing needs, offering an extensive catalog of nearly 600, 000 SKUs that cover hardware, mechanical, electronic, and automation components. Their commitment to guaranteeing genuine products, rapid shipping (with most in-stock items dispatched within 24 hours), and competitive pricing truly sets them apart. In addition, their exceptional customer service ensures you always get exactly what you need to bring your projects to life.
For my next project, Iβm planning to buy a timing belt from their Transmission Components section.
What I really like is how easy it is to customize the part. On the left side, you can select all the required options, and just below that, you get the complete specification and documentation, so you know exactly what youβre ordering.
JLCMC has recently upgraded their new-user registration benefits, increasing the value of the welcome coupon package to $123 in discount coupons. Whether youβre building DIY electronics, robotics, or mechanical projects, JLCMC has you covered with quality parts and fast delivery. Donβt miss outβvisit https://jlcmc.com/?from=RL2to explore their amazing range of products and grab your discount coupon today!
Step 3: The Build- Start with the lid β press all 6 Gateron mechanical switches into their designated cutouts. They should snap in with a satisfying click and sit flush. No glue needed here; the cutout tolerances hold them firmly in place.
- Take your 0.9" SSD1306 OLED display and apply a small amount of hot glue around the edges of the display slot on the lid, then press the display in face-first. Hold it for 30 seconds until the glue sets. Make sure it sits flat and centered β this is what people will look at most.
- Now for the switch wiring β and this is where this build is simpler than most macropad projects you'll find online. Normally macropads use a matrix wiring method, where rows and columns of switches share wires to save microcontroller pins. For example, a 3Γ2 matrix uses only 5 pins instead of 6. It's efficient but requires diode soldering on every switch and more complex firmware logic to scan properly. In this build we skip all of that entirely. Since the XIAO ESP32-S3 has enough GPIO pins available, we wire each switch directly and independently to its own dedicated pin β one wire per switch to a GPIO, and all switches share a single common ground wire. This is called direct pin wiring and it's simpler to solder, simpler to debug, and simpler to code.
- Solder a wire from one leg of each switch to its assigned GPIO pin and connect the other leg of all switches to a common GND on the XIAO ESP32-S3. Follow this pin mapping exactly:Key 1 (top-left) β D6 β DiscordKey 2 (top-middle) β D3 β VS CodeKey 3 (top-right) β D2 β SpotifyKey 4 (bottom-left) β D1 β Previous TrackKey 5 (bottom-middle) β D0 β Play/PauseKey 6 (bottom-right) β D8 β Next Track
- For the OLED display, solder four wires from the display module to the XIAO ESP32-S3 β VCC to 3.3V, GND to GND, SDA to D4, and SCL to D5. The display runs on I2C so only these two data lines are needed.
- Mount the XIAO ESP32-S3 into the designated slot in the base. It should sit snugly. If it feels loose, a small dab of hot glue on the sides will hold it permanently without blocking the USB-C port.
- Now carefully snap the lid onto the base, routing all the wires inside cleanly so nothing gets pinched between the two parts. Take your time here β neat wire routing makes the difference between a clean build and a messy one.
- Finally, press all 6 keycaps onto the switch stems. They should click on firmly. If any feel loose, a tiny piece of tape around the stem shank fixes it instantly.
That's the full hardware build done. β
Step 4: CodingNow we will upload the program to the Seeed Studio XIAO ESP32-S3.
1. Install Arduino IDE and ESP32 Board Package- Install the latest version of Arduino IDE on your computer.
- Open Arduino IDE and install the ESP32 board package:
- Go to File β Preferences
- In Additional Board Manager URLs, add:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
- Then go to:
- Tools β Board β Boards Manager
- Search for ESP32 and install the ESP32 board package.
- Open Arduino IDE and configure the board.
- Go to:
- Tools β Board β ESP32 Arduino β XIAO ESP32S3
- Then select the correct COM port:
- Tools β Port β Select the port connected to your XIAO ESP32-S3
This is the most important setting and the one most people miss. By default the XIAO ESP32-S3 uses UART CDC mode for serial communication, which means your PC sees it as a serial device β not a keyboard. You need to switch it to USB-OTG mode so Windows/Mac recognizes it as a native HID keyboard the moment you plug it in.
In Arduino IDE go to:
- Tools β USB Mode β USB-OTG (TinyUSB)
Without this change, the keyboard and media key code will compile and upload fine, but absolutely nothing will happen when you press the keysβthe PC simply won't receive any HID input. Set this once and you never need to touch it again for this board.
4. Install Required Libraries- Install the following libraries from the Arduino Library Manager.
- Go to Sketch β Include Library β Manage Libraries and install:
- Adafruit GFX
- Adafruit SSD1306
- FluxGarage RoboEyes
- These libraries are used for the OLED display, and animated robot eyes.
/*
* ============================================================
* Code by: Shahbaz Hashmi Ansari
* MACROPAD β XIAO ESP32-S3 (Standalone USB HID β No Python!)
* 6 Kailh keys (direct wiring, no matrix)
* 0.96" SSD1306 OLED (I2C) + FluxGarage RoboEyes
* Works as a native USB Keyboard + Media Controller
* ============================================================
*/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <FluxGarage_RoboEyes.h>
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDConsumerControl.h"
// βββ USB HID ββββββββββββββββββββββββββββββββββββββββββββββββ
USBHIDKeyboard Keyboard;
USBHIDConsumerControl ConsumerControl;
// βββ OLED βββββββββββββββββββββββββββββββββββββββββββββββββββ
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
RoboEyes<Adafruit_SSD1306> roboEyes(display);
// βββ KEY PINS βββββββββββββββββββββββββββββββββββββββββββββββ
// Key1 Key2 Key3 Key4 Key5 Key6
const int KEY_PINS[] = {2, 1, 7, 43, 4, 3,};
const int NUM_KEYS = 6;
// βββ DEBOUNCE βββββββββββββββββββββββββββββββββββββββββββββββ
#define DEBOUNCE_MS 300
unsigned long lastPress[6] = {0};
// βββ EYE RESET TIMER ββββββββββββββββββββββββββββββββββββββββ
unsigned long eyeActionTime = 0;
#define EYE_RESET_MS 3000 // return to idle after 3 s
// βββ FORWARD DECLARATIONS βββββββββββββββββββββββββββββββββββ
void handleKey(int idx);
void resetEyes();
void doOpenPowerShell();
void doVSCodeOpen();
void doOpenSpotify();
void doPrevTrack();
void doPlayPause();
void doNextTrack();
typedef void (*ActionFunc)();
ActionFunc KEY_ACTIONS[] = {
doOpenPowerShell, // Key 1 β ROW 1
doVSCodeOpen, // Key 2 β ROW 1
doOpenSpotify, // Key 3 β ROW 1
doPrevTrack, // Key 4 β ROW 2
doPlayPause, // Key 5 β ROW 2
doNextTrack // Key 6 β ROW 2
};
// =============================================================
// SETUP
// =============================================================
void setup() {
for (int i = 0; i < NUM_KEYS; i++) {
pinMode(KEY_PINS[i], INPUT_PULLUP);
}
Keyboard.begin();
ConsumerControl.begin();
USB.begin();
Wire.begin(5, 6); // SDA=GPIO5, SCL=GPIO6
if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
// Halt β OLED required for RoboEyes
while (true) { delay(500); }
}
// ββ RoboEyes init ββββββββββββββββββββββββββββββββββββββββββ
roboEyes.begin(SCREEN_WIDTH, SCREEN_HEIGHT, 30); // 30 fps max
roboEyes.setAutoblinker(ON, 3, 2); // blink every 3Β±2 s
roboEyes.setIdleMode(ON, 2, 1); // look around every 2Β±1 s
roboEyes.setMood(DEFAULT);
roboEyes.setCuriosity(ON); // outer eye grows on side-look
roboEyes.open();
}
// =============================================================
// MAIN LOOP
// =============================================================
void loop() {
// ββ Poll keys ββ
for (int i = 0; i < NUM_KEYS; i++) {
if (digitalRead(KEY_PINS[i]) == LOW) {
unsigned long now = millis();
if (now - lastPress[i] > DEBOUNCE_MS) {
lastPress[i] = now;
handleKey(i);
}
}
}
// ββ Return eyes to idle state after action timeout ββ
if (eyeActionTime > 0 && millis() - eyeActionTime > EYE_RESET_MS) {
eyeActionTime = 0;
resetEyes();
}
// ββ Drive eye animations (non-blocking) ββ
roboEyes.update();
}
// =============================================================
// Handle key β execute action then arm eye reset timer
// =============================================================
void handleKey(int idx) {
KEY_ACTIONS[idx]();
eyeActionTime = millis();
}
// =============================================================
// Reset eyes to relaxed idle state
// =============================================================
void resetEyes() {
roboEyes.setMood(DEFAULT);
roboEyes.setPosition(DEFAULT);
roboEyes.setCuriosity(ON);
roboEyes.setAutoblinker(ON, 3, 2);
roboEyes.setIdleMode(ON, 2, 1);
}
// =============================================================
// KEY 1 β PowerShell (Admin)
// Eye: ANGRY β squinting, focused, ready for battle
// =============================================================
void doOpenPowerShell() {
roboEyes.setAutoblinker(OFF, 3, 2);
roboEyes.setIdleMode(OFF, 2, 1);
roboEyes.setMood(ANGRY);
roboEyes.setPosition(DEFAULT);
Keyboard.press(KEY_LEFT_GUI);
Keyboard.press('x');
delay(150);
Keyboard.releaseAll();
delay(600);
Keyboard.press('a');
delay(100);
Keyboard.releaseAll();
}
// =============================================================
// KEY 2 β VS Code (open app + Command Palette)
// Fix: Win+R β "code" β Enter launches VS Code reliably.
// Waits for load, then fires Ctrl+Shift+P.
// Eye: HAPPY + curious β excited coder ready to ship
// =============================================================
void doVSCodeOpen() {
roboEyes.setAutoblinker(OFF, 3, 2);
roboEyes.setIdleMode(OFF, 2, 1);
roboEyes.setMood(HAPPY);
roboEyes.setCuriosity(ON);
roboEyes.setPosition(N); // eyes look up β let's code!
// Open Run dialog
Keyboard.press(KEY_LEFT_GUI);
Keyboard.press('r');
delay(150);
Keyboard.releaseAll();
delay(400);
// Type VS Code CLI command
Keyboard.print("code");
delay(100);
Keyboard.press(KEY_RETURN);
delay(100);
Keyboard.releaseAll();
// Wait for VS Code to focus/open, then fire command palette
delay(2000);
Keyboard.press(KEY_LEFT_CTRL);
Keyboard.press(KEY_LEFT_SHIFT);
Keyboard.press('p');
delay(100);
Keyboard.releaseAll();
}
// =============================================================
// KEY 3 β Spotify
// Eye: HAPPY + anim_laugh β bouncing with the beat
// =============================================================
void doOpenSpotify() {
roboEyes.setAutoblinker(OFF, 3, 2);
roboEyes.setIdleMode(OFF, 2, 1);
roboEyes.setMood(HAPPY);
roboEyes.anim_laugh(); // eyes bounce up & down
Keyboard.press(KEY_LEFT_GUI);
delay(100);
Keyboard.releaseAll();
delay(500);
Keyboard.print("Spotify");
delay(700);
Keyboard.press(KEY_RETURN);
delay(100);
Keyboard.releaseAll();
}
// =============================================================
// KEY 4 β Previous Track
// Eye: look hard left β rewind!
// =============================================================
void doPrevTrack() {
roboEyes.setAutoblinker(OFF, 3, 2);
roboEyes.setIdleMode(OFF, 2, 1);
roboEyes.setMood(DEFAULT);
roboEyes.setCuriosity(ON);
roboEyes.setPosition(W); // look left
ConsumerControl.press(CONSUMER_CONTROL_SCAN_PREVIOUS);
delay(100);
ConsumerControl.release();
}
// =============================================================
// KEY 5 β Play / Pause
// Eye: satisfying slow blink β chill
// =============================================================
void doPlayPause() {
roboEyes.setAutoblinker(OFF, 3, 2);
roboEyes.setIdleMode(OFF, 2, 1);
roboEyes.setMood(DEFAULT);
roboEyes.setPosition(DEFAULT);
roboEyes.close();
delay(200);
roboEyes.open();
ConsumerControl.press(CONSUMER_CONTROL_PLAY_PAUSE);
delay(100);
ConsumerControl.release();
}
// =============================================================
// KEY 6 β Next Track
// Eye: look hard right β skip!
// =============================================================
void doNextTrack() {
roboEyes.setAutoblinker(OFF, 3, 2);
roboEyes.setIdleMode(OFF, 2, 1);
roboEyes.setMood(DEFAULT);
roboEyes.setCuriosity(ON);
roboEyes.setPosition(E); // look right
ConsumerControl.press(CONSUMER_CONTROL_SCAN_NEXT);
delay(100);
ConsumerControl.release();
}Step 5: Satisfying Click SoundWords can't do this justice β just press play.
These are Kailh Blue mechanical switches β clicky, tactile, and genuinely satisfying to type on. Every keypress gives you that crisp audible feedback that just feels right. It's half the reason to build this over a membrane alternative.
Watch the video above and try not to smile. π
Step 6: Working Video and TutorialAnd that's it β your macropad is alive. π
Plug it in, watch the eyes blink open, press a key, and see it react. That moment never gets old honestly. What started as a simple productivity tool ended up becoming something that actually has character sitting on your desk.
If you build this, I'd genuinely love to see it. Drop a photo in the commentsβespecially if you remix the case, change the keycap icons, or add your own eye expressions to the code. That's the best part of open builds like this.
Got stuck somewhere? Leave a comment and I'll help you debug it.
For business or collaboration inquiries, reach out via email at shahbazhashmi006@gmail.com.
Follow me here on Instructables so you don't miss the next build, and if you want to see this macropad actually in action, the full build video is on my YouTube channel.
YouTube: roboattic Lab
See you in the next one. π§





_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)



Comments