DIY Macropad with REACTIVE Animated Eyes!

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

HARDWARE LIST
1 Seeed Studio XIAO ESP32-S3
1 0.9" OLED display
6 Kailh mechanical switches
1 USB-C Cable
STEP 1
CAD & 3D Printing

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

icon Macropad 3D .stl Files.zip 149KB Download(0)
STEP 2
levate Your Electronic Projects - JLCMC

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!

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 โ€” 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. โœ…

STEP 4
Coding

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
/*
 * ============================================================
 *  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 Sound

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. ๐Ÿ˜„

STEP 6
Working Video and Tutorial

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. ๐Ÿ”ง

License
All Rights
Reserved
licensBg
0