← Writing
3 min read

Layers, not keys: rethinking a 36-key keymap

A small split keyboard looks impossible until you stop counting keys and start designing layers. How I approach home-row mods, layer-taps, and the jump from QMK to ZMK.

keyboardsqmkzmkfirmware

The first time you see a 36-key split keyboard, the reaction is always the same: where did all the keys go? No number row, no dedicated symbols, half the modifiers missing. It looks like a constraint you'd have to suffer through.

It isn't. The trick is that a small board forces you to stop thinking in keys and start thinking in layers. Once that clicks, 36 keys is plenty — and going back to a full-size board feels like reaching across a desk for things that should be under your fingers.

The base layer is the smallest decision

On a normal keyboard the base layer is the whole story. On a small one it's maybe a third of it. The base layer holds letters and not much else, which means every physical key can pull double duty through hold-tap behaviours: tap for a letter, hold for a modifier or a layer.

The two that carry the most weight:

  • Home-row mods — the eight keys under your resting fingers become Shift/Ctrl/Alt/GUI when held, letters when tapped.
  • Layer-taps on the thumbs — hold to reach a symbol or number layer, tap for space/enter/backspace.

QMK: home-row mods and layer-taps

In QMK this is mostly MT() (mod-tap) and LT() (layer-tap) plus some tuning so fast typing doesn't fire modifiers by accident.

keymap.c
// Home-row mods on the left hand: A=GUI, S=Alt, D=Ctrl, F=Shift
#define HM_A LGUI_T(KC_A)
#define HM_S LALT_T(KC_S)
#define HM_D LCTL_T(KC_D)
#define HM_F LSFT_T(KC_F)
 
// Thumbs: hold for a layer, tap for a key
#define TH_NAV LT(NAV, KC_SPC)
#define TH_SYM LT(SYM, KC_ENT)
 
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
  [BASE] = LAYOUT_split_3x5_2(
    KC_Q, KC_W, KC_E,  KC_R,  KC_T,   KC_Y, KC_U,  KC_I,  KC_O,   KC_P,
    HM_A, HM_S, HM_D,  HM_F,  KC_G,   KC_H, KC_J,  KC_K,  KC_L,   KC_SCLN,
    KC_Z, KC_X, KC_C,  KC_V,  KC_B,   KC_N, KC_M,  KC_COMM, KC_DOT, KC_SLSH,
                     TH_NAV, KC_TAB,  KC_BSPC, TH_SYM
  ),
};

The part nobody tells you: the defaults will feel broken for a week. Home-row mods live or die on timing configTAPPING_TERM, and especially the newer HOLD_ON_OTHER_KEY_PRESS and per-key get_tapping_term() overrides. Get those wrong and "the" turns into a Ctrl-chord. Get them right and you forget the mods are there at all.

Moving to ZMK for wireless

When I cut the cable, QMK's C gave way to ZMK's devicetree. The concepts map over cleanly — the same hold-taps, just declared instead of coded:

base.keymap
/ {
    behaviors {
        hm: home_row_mod {
            compatible = "zmk,behavior-hold-tap";
            flavor = "balanced";
            tapping-term-ms = <200>;
            quick-tap-ms = <150>;
            bindings = <&kp>, <&kp>;
        };
    };
 
    keymap {
        compatible = "zmk,keymap";
        base {
            bindings = <
                &kp Q &kp W &kp E &kp R &kp T   &kp Y &kp U &kp I &kp O &kp P
                &hm LGUI A &hm LALT S &hm LCTL D &hm LSFT F &kp G   /* ... */
            >;
        };
    };
};

flavor is the interesting knob. "balanced" waits to see whether you're rolling into another key (a tap) or genuinely holding (a mod), which is exactly the ambiguity home-row mods have to resolve. It's the closest thing ZMK has to QMK's per-key timing surgery, and for most hands it's enough.

What actually made it stick

Two habits, not two features:

  1. One change at a time. Every layer tweak is a new muscle-memory contract. Change five things and you can't tell which one you hate.
  2. Log your misfires. For a while I kept a note of every accidental modifier and every reach that hurt. The keymap that emerged after a month of that was better than anything I designed up front.

Thirty-six keys stopped being a constraint and started being the point.