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