QMK keymap for the Smallcat

January 2026 - 10 min read

Introduction

The Smallcat (4f5801b) is a 26-key keyboard I designed. Named after my cat Lila, who acts as a muse through a silkscreen on the PCB, it’s a diodeless, column-staggered keyboard using low-profile Choc switches.

Lila the cat, who figures on the Smallcat keyboard silkscreen

It comes in two versions: split (two RP2040-Zero controllers connected via TRRS) and unibody (single RP2040 Pro Micro).

Smallcat split keyboard with two halves connected via TRRS cable

Smallcat unibody keyboard with single RP2040 Pro Micro controller

26 keys is not a lot. To make it work, the QMK keymap (d9dc6c4) relies heavily on layers, combos, and tap-hold keys. This post walks through some of the interesting design choices.

I. The layout

The letter arrangement is heavily customized for my workflow and optimized for English. I started with Colemak-DH, then moved to Canary, then Graphite. Eventually I decided to create my own layout, borrowing ideas from all three.

The layout prioritizes minimizing pinky strain. Every key on the home row and bottom row does double duty through tap-hold:

     +----+----+----+                    +----+----+----+
     | L  | D  | P  |                    | F  | O  | U  |
+----+----+----+UNI-+----+          +----+----+----+----+----+
| W  | R  | T  | S  | G  |          | Y  | N  | A  | E  | I  |
+LSft+SYM2+NUM-+NAV-+ROS-+          +----+SYS-+----+SYM2+RSft+
     | K  | M  | C  |                    | H  | ,  | .  |
     +LCtl+LAlt+LGui+                    +RGui+RAlt+RCtl+
               +----+----+    +----+----+
               |Tab |Spc |    |Bsp |Ent |
               +EDIT+FUN-+    +SYS-+SYM-+
copy

Holding a key activates its layer. Tapping types the letter. This is achieved through QMK's LT() and MT() macros:

#define K_LH2 LT(SYM2, KC_R)  // hold: SYM2 layer, tap: R
#define K_LH3 LT(NUM, KC_T)   // hold: NUM layer, tap: T
#define K_LB1 MT(MOD_LCTL, KC_K)  // hold: Ctrl, tap: K
copy

The keymap has 11 (!) layers total. Here's what each one does:

  • NUM (hold T), number pad on the right hand with 0-9, a triple zero for large numbers, and decimal/comma.
  • SYM (hold Enter), symbols like @, $, %, =, and programming operators. The arrow operator (->) lives here too.
  • SYM2 (hold R or E), same symbols as SYM but with home row letters passed through, allowing fast rolls without fighting the layer system.
  • NAV (hold S), arrow keys, brackets, and Home/End navigation.
  • NAV2 (hold S + T), word-level navigation and selection. Move by word, select words forward/backward, select entire lines.
  • EDIT (hold Tab), clipboard operations (cut, copy, paste, undo, redo), find, and select all.
  • FUN (hold Space), function keys F1-F12. F10-F12 are accessed via combos on F7-F9.
  • SYS (hold N or Backspace), mouse control, media playback, volume, brightness, and zoom.
  • ROS (hold G), a specialized layer for controlling robots via ROS2 teleoptwistkeyboard.
  • UNI (hold P), French accented characters and special symbols like guillemets.

Some layers have multiple access points. SYS can be activated by holding N (home row) or Backspace (thumb). SYM2 can be activated by holding R or E. This flexibility means you can choose whichever finger is free, and it makes certain key combinations more comfortable depending on which hand is doing the work.

II. Modular layer definitions

With 11 layers, keeping the keymap maintainable is important. The config uses a macro-based system that splits each layer into left and right hand definitions, making it easy to reason about and modify.

Each layer is defined as 8 small macros, one per hand and row:

// BASE layer: left hand
#define BASE_L_TOP   K_LT1, K_LT2, K_LT3
#define BASE_L_HOME  K_LH1, K_LH2, K_LH3, K_LH4, K_LH5
#define BASE_L_BOT   K_LB1, K_LB2, K_LB3
#define BASE_L_THUMB K_LTH1, K_LTH2

// BASE layer: right hand
#define BASE_R_TOP   K_RT1, K_RT2, K_RT3
#define BASE_R_HOME  K_RH1, K_RH2, K_RH3, K_RH4, K_RH5
#define BASE_R_BOT   K_RB1, K_RB2, K_RB3
#define BASE_R_THUMB K_RTH1, K_RTH2
copy

A LAYER_DEF macro assembles these parts into a full layer:

#define LAYOUT_wrapper(...) LAYOUT(__VA_ARGS__)
#define LAYER_DEF(layer) LAYOUT_wrapper( \
    layer##_L_TOP,   layer##_R_TOP, \
    layer##_L_HOME,  layer##_R_HOME, \
    layer##_L_BOT,   layer##_R_BOT, \
    layer##_L_THUMB, layer##_R_THUMB \
)
copy

The ## token pasting operator concatenates the layer name with each suffix. So LAYER_DEF(BASE) expands to use BASE_L_TOP, BASE_R_TOP, and so on. The keymap array becomes concise:

const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
    [BASE] = LAYER_DEF(BASE),
    [NUM]  = LAYER_DEF(NUM),
    [SYM]  = LAYER_DEF(SYM),
    // ... and so on
};
copy

This pattern has a few nice properties. First, you can reuse parts across layers. The SYM2 layer shares most keys with SYM:

#define SYM2_L_TOP  SYM_L_TOP   // reuse SYM's top row
#define SYM2_L_BOT  SYM_L_BOT   // reuse SYM's bottom row
#define SYM2_L_HOME S(KC_8), KC_R, KC_AMPR, KC_PIPE, S(KC_5)  // custom
copy

Second, the key aliases follow a naming convention that makes the layout self-documenting. K_LH2 means "left hand, home row, 2nd key". K_RT3 means "right hand, top row, 3rd key". The pattern is K_[L|R][T|H|B|TH][1-5] where T is top, H is home, B is bottom, and TH is thumb. This makes it easy to reason about key positions without counting indices.

These aliases also encode modifiers and layer-taps at the key level, not the layer level. This keeps layer definitions clean and ensures combos work consistently since they reference positions, not raw keycodes:

#define K_LH1 MT(MOD_LSFT, KC_W)   // hold: Shift, tap: W
#define K_LH2 LT(SYM2, KC_R)       // hold: SYM2, tap: R
#define K_LTH1 LT(EDIT, KC_TAB)    // hold: EDIT, tap: Tab
copy

Adding a new layer means defining 8 small macros and one LAYER_DEF line. The same definitions work across both Smallcat variants (split and unibody) since they share the same smallwat3r.c file.

III. Combos for missing keys

With only 26 keys, several letters are missing: Z, Q, B, X, V, J, some of the least used in English. B and V are more common, but their combos are near the home row for easy access, and the V combo feels natural. Combos are mostly positioned on keys that don't repeat, to avoid accidental activations. Combos are defined in combos.def, a special file that QMK preprocesses to generate the boilerplate code for each combo automatically. Here is an example:

COMB(C_BL_Z, KC_Z, K_LT1, K_LT2, K_LT3)  // L+D+P = Z
COMB(C_BL_Q, KC_Q, K_LT2, K_LT3)         // D+P = Q
COMB(C_BL_B, KC_B, K_LH2, K_LH3)         // R+T = B
// ...
copy

Beyond missing letters, combos handle common digraphs and text shortcuts:

SUBS(C_BL_TH, "th", K_LH3, K_LH4)         // T+S = "th"
SUBS(C_BL_WH, "wh", K_LH1, K_LH4)         // W+S = "wh"
SUBS(C_BR_ING, "ing", K_RH2, K_RH5)       // N+I = "ing"
SUBS(C_BL_WC, ".com", K_LH1, K_LB3)       // W+C = ".com"
SUBS(C_BL_WP, "@gmail.com", K_LH1, K_LT3) // W+P = "@gmail.com"
// ...
copy

Brackets also live on combos, positioned to mirror each other:

L+P = [    F+U = ]
K+C = (    H+. = )
copy

IV. The SYM2 layer trick

One subtle problem with layer-tap keys: if you hold R to access SYM2 and then tap E for a symbol, you can't "roll" through R-E quickly because releasing R first would cancel the layer.

The solution is SYM2, a symbol layer that passes through the home row letters. When you hold R and tap A, you get lowercase 'a', not a symbol:

// SYM2: symbols with home row passthrough for rolls
#define SYM2_R_HOME S(KC_1), S(KC_SCLN), KC_A, KC_E, S(KC_BSLS)
copy

This allows fast typing without fighting the layer system.

V. Custom tap-hold behavior

Some keys need special tap-hold logic that QMK doesn't provide out of the box. The R+S combo is a good example:

  • Tap: types "sh"
  • Hold: activates Ctrl+Gui (useful for window management)

A small struct tracks the key state:

typedef struct {
    uint16_t keycode;
    uint16_t time;
} tap_hold_state_t;

static tap_hold_state_t tap_hold_state = {0};
copy

On press, the keycode and timestamp are stored, and the hold modifier is registered immediately:

case MK_RS:
    tap_hold_state.keycode = keycode;
    tap_hold_state.time = record->event.time;
    register_mods(MOD_BIT(KC_LCTL) | MOD_BIT(KC_LGUI));
    return false;
copy

On release, the elapsed time determines whether it was a tap or hold. If the time since key press is less than TAPPING_TERM (220ms), it's a tap:

bool is_tap = TIMER_DIFF_16(record->event.time, tap_hold_state.time) < TAPPING_TERM;

case MK_RS:
    unregister_mods(MOD_BIT(KC_LCTL) | MOD_BIT(KC_LGUI));
    if (is_tap) SEND_STRING("sh");
    break;
copy

VI. Chordal hold

A common frustration with home-row mods is accidental activation when typing quickly. QMK's chordal hold feature helps, it only triggers a hold if the next key is on the opposite hand:

const char chordal_hold_layout[MATRIX_ROWS][MATRIX_COLS] PROGMEM =
    LAYOUT(
             'L', 'L', 'L',            'R', 'R', 'R',
        'L', 'L', 'L', 'L', 'L',  'R', 'R', 'R', 'R', 'R',
             'L', 'L', 'L',            'R', 'R', 'R',
                       '*', '*',  '*', '*'
    );
copy

The 'L' and 'R' markers tell QMK which hand each key belongs to. Thumbs are marked '*' to work with either hand.

VII. Per-combo timing

Not all combos are equal. Some key pairs are easier to hit together than others. The config defines different timing windows:

#define COMBO_TERM 35
#define COMBO_TERM_RELAXED 50
#define COMBO_TERM_TIGHT 20
copy

Brackets get tight timing (20ms) because false positives are annoying. Digraphs like "th" get relaxed timing (50ms) because they're typed in sequence:

uint16_t get_combo_term(uint16_t combo_index, combo_t *combo) {
    switch (combo_index) {
        case C_BL_TH:
        case C_BL_WH:
            return COMBO_TERM_RELAXED;
        case C_BL_LBRC:
        case C_BR_RBRC:
            return COMBO_TERM_TIGHT;
        // ...
    }
    return COMBO_TERM;
}
copy

VIII. Unicode for French

The UNI layer (hold P) provides French accented characters. QMK's unicode feature maps key presses to unicode code points:

const uint32_t PROGMEM unicode_map[] = {
    [UN_E_ACUTE] = 0x00E9,      // é
    [UN_E_GRAVE] = 0x00E8,      // è
    [UN_C_CEDILLA] = 0x00E7,    // ç
    [UN_OE_LIGATURE] = 0x0153,  // œ
    // ...
};
copy

The keyboard auto-detects the OS and switches unicode input mode:

switch (detected_os) {
    case OS_MACOS:
        set_unicode_input_mode(UNICODE_MODE_MACOS);
        break;
    case OS_LINUX:
        set_unicode_input_mode(UNICODE_MODE_LINUX);
        break;
}
copy

IX. Custom features

The keymap uses several custom features, some written from scratch and some adapted from Pascal Getreuer's excellent QMK extensions.

a) OS-aware keys

Common operations like copy, paste, undo, and word navigation use different modifiers on macOS (Cmd) vs Linux/Windows (Ctrl). The os_keys feature detects the OS and sends the right shortcut. Covers clipboard, navigation, zoom, find, and more.

case OS_COPY:
    OS_TAP(G(KC_C), C(KC_C));  // Cmd+C on Mac, Ctrl+C elsewhere
case OS_L_W:
    OS_REPEAT(A(KC_LEFT), C(KC_LEFT));  // word left
copy

b) Select word

From Pascal Getreuer’s collection. Select words and lines with a single key, hold to extend the selection. Handles OS differences internally.

case MK_SEL_BACK:
    select_word_register('B');  // select word backward
case MK_SEL_LINE:
    select_word_register('L');  // select entire line
copy

c) Sentence case

Another Getreuer feature. Automatically capitalizes the first letter after sentence-ending punctuation. Useful for prose, toggled off for code.

d) Autocorrect

QMK’s built-in feature that fixes common typos as you type. A custom dictionary in autocorrection_dict.txt gets compiled into a binary trie via make autocorrect. A : prefix or suffix marks a word boundary, preventing false corrections within longer words. Sample entries:

becuase    -> because
definately -> definitely
seperate   -> separate
wierd      -> weird
:ture      -> true
:wont:     -> won't
copy

Without the leading :, ture -> true would incorrectly turn “feature” into “featrue”. :wont: requires word breaks on both sides, so “unwonted” won’t become “unwon’ted”.

e) LED/RGB indicator

The onboard LED or RGB (depending on the microcontroller) provides visual feedback: solid on for Caps Word, slow blink for One-Shot Shift, double flash when autocorrect applies a correction, and three blinks on startup to indicate connection. QMK provides hooks to trigger these states:

bool caps_word_set_user(bool active) {
    caps_word_active = active;
    indicator_set(active);
    return true;
}

bool apply_autocorrect(uint8_t backspaces, const char *str, char *typo, ...) {
    led_indicator_flash(2);  // double flash on correction
    return true;
}
copy

X. Light switches

With all these layers and combos, key feel matters. I use Kailh Ambients Nocturnal switches, 20g actuation force. They're absurdly light, which helps when you're pressing multiple keys for combos. They're also very silent, making them suitable for shared workspaces.

~~~

Wrapping up

This keymap is in constant evolution. Being able to shape how I communicate with my machine, fully customised and optimised to my workflow, is truly amazing.

← back