Building a Custom Macropad from Scratch


A Stream Deck costs £150 and has more software than I want to think about. A cheap macropad from AliExpress doesn’t have the layout I want and ships with firmware I can’t inspect. So I built one.

This is a 4×4 grid — 16 keys, two layers, no display, no RGB. Just switches, a chip, and a case that fits the desk.

4×4 macropad — RP2040, hot-swap sockets, SLS Nylon case

The PCB

The schematic is straightforward: 16 switches in a matrix (4 columns × 4 rows), each column pulled low through the RP2040’s GPIO, each row read on a separate pin. No diodes needed for a 4×4 grid if you’re only pressing one key at a time, but I added them anyway for proper n-key rollover.

The RP2040 is a good choice here. It’s cheap, has plenty of GPIO, and QMK has solid support for it. I used the minimal reference design from the datasheet — XOSC, decoupling caps, USB-C with ESD protection, and a BOOTSEL button wired to a test pad rather than a through-hole button.

Matrix wiring:
  COL0-COL3  → GP0-GP3  (driven low to scan)
  ROW0-ROW3  → GP4-GP7  (read with internal pull-ups)
  USB D+/D-  → GP15/GP16 via RP2040-E5 resistors

Hot-swap sockets make switch swapping possible without a rework station. I used Kailh MX sockets. The footprint is a tight fit — double-check your pad spacing against the actual socket dimensions, not just the datasheet.

Bare PCB — RP2040, switch matrix, hot-swap sockets

Case Design

The case is two-part: a top plate with cutouts for the switches, and a bottom shell that captures the PCB on standoffs. Designed in Fusion 360, printed in Nylon 12 on a Formlabs Fuse 1.

SLS is the right choice for this kind of enclosure. The part strength is consistent in all directions, the surface finish is acceptable without post-processing, and there’s no support removal. The tolerances are predictable once you’ve dialled in your shrinkage compensation (I use 3% on Nylon 12).

The top plate uses standard MX switch cutout dimensions (14mm square, 1.5mm thick). Switch retention is tight enough that they don’t fall out during assembly without needing clip-in retention tabs.

Firmware

QMK is the obvious choice. Setup for an RP2040-based board:

# Clone QMK and set up the environment
qmk setup
qmk new-keyboard
# Select: RP2040, custom matrix, handwired

The keymap has two layers. Layer 0 is my coding layout — window management, clipboard shortcuts, IDE actions. Layer 1 is media and system controls, toggled by holding the bottom-right key.

const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
  [0] = LAYOUT(
    // Row 0: window management
    LGUI(KC_LEFT),  LGUI(KC_UP),   LGUI(KC_RIGHT), MO(1),
    // Row 1: IDE shortcuts
    LCTL(KC_GRV),   LCTL(KC_P),    LSFT(LCTL(KC_P)), LCTL(KC_B),
    // Row 2: clipboard
    LCTL(KC_Z),     LCTL(KC_X),    LCTL(KC_C),     LCTL(KC_V),
    // Row 3: misc
    LCTL(KC_S),     LCTL(KC_W),    LALT(KC_F4),    KC_MUTE
  ),
  [1] = LAYOUT(
    KC_MPRV, KC_MPLY, KC_MNXT, _______,
    KC_VOLD, KC_VOLU, KC_MUTE, _______,
    KC_BRID, KC_BRIU, _______, _______,
    _______, _______, _______, _______
  ),
};

Debouncing

The default QMK debounce algorithm (symmetric eager per-key) worked fine for the Gateron Browns I started with. When I switched to Kailh Box Whites, I got occasional ghost inputs on fast double-taps. Switching to sym_defer_g (global defer) fixed it.

// config.h
#define DEBOUNCE 8  // ms — increase if you're still seeing ghosts

What I’d Change

The USB-C port placement is too close to the bottom edge. It makes the connector feel fragile when a cable is plugged in and someone knocks it. Next revision I’ll move it to the side.

The Nylon surface picks up finger grease quickly. I’m debating a light coat of matte varnish or switching to dyed Nylon for the production run.

Otherwise it works exactly as intended. The key layout has become muscle memory in about a week.