
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. ๐
ย
Supplies


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. โ




JLCMC 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=RL2 to explore their amazing range of products and grab your discount coupon today!

โข 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 โ Discord
Key 2 (top-middle) โ D3 โ VS Code
Key 3 (top-right) โ D2 โ Spotify
Key 4 (bottom-left) โ D1 โ Previous Track
Key 5 (bottom-middle) โ D0 โ Play/Pause
Key 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. โ

Now 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.
2. Select the Board and Port
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
3. Configure USB Mode โ Critical Step
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.
5. Copy the Project Code
/*
* ============================================================
* 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();
}Words 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. ๐
And 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 [email protected].
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. ๐ง









