This is my personal ZMK firmware configuration. At its core is a 34-key base keymap that adapts to boards of various sizes and layouts (currently a Corneish Zen, a Glove80 and a Planck).
The repository doubles as the root of my ZMK workspace: a single manifest file declares the entire
firmware — ZMK, Zephyr and every module — and pins it to exact revisions, so the config can track
upstream main without the risk of silent breakage. Builds run in the cloud as usual, or locally in
a nix environment that sets itself up automagically whenever I enter the workspace.
- Top-down workspace bootstrapped from a single declarative manifest
- Fully locked firmware via pin-west, with automated weekly update PRs
- Self-installing, isolated local build environment, powered by nix
justrecipes for building, flashing, and drawing the keymap- Various developer tools for formatting Devicetree files, testing ZMK modules, etc
- "Timeless" homerow mods — homerow mods without timing headaches
- Combos instead of a symbol layer
- Numword and smart-mouse: layers that toggle off automatically
- Magic thumb quadrupling as Repeat/Sticky-shift/Shift/Capsword
- Leader key sequences for Unicode input and system commands
- Arrow cluster doubling as home, end, begin/end of document on long-press
- Shifted actions that make sense: , ↦ ;, . ↦ : and ? ↦ !
- Simplified Devicetree syntax using helper macros from zmk-helpers
If you are looking to adapt this config for your own keyboard, Part III gives some pointers.
ZMK's cloud-workflow has always been "top-down": config/west.yml declares what goes into the
firmware, and build-user-config takes it from there. However, the recommended local
setup inverts this: ZMK itself becomes the
root of the workspace, and one adds the config and any other modules on top by hand.
This repo keeps config/west.yml as
the single source of truth for both. The west manifest declares ZMK, Zephyr and every module — each
pinned to an exact revision — and everything else is derived from it: just init bootstraps the workspace from the manifest, a self-installing nix environment supplies the
entire toolchain, and local and cloud builds stay in sync by construction.
What that buys, concretely:
- A single repo as the source of truth. There are no separate ZMK, ZMK config and ZMK module repos to clone and coordinate. The firmware is declared in a single manifest file in the same repo that configures the keymap.
- Nothing to assemble by hand. Clone the repo and run
just initto set up a local workspace mirroring the cloud build.zmk/,zephyr/andmodules/are generated — delete them andjust initrecreates them exactly. - Easy experimentation. None of this compromises development. Check out a new branch in any module to try out or develop new features, and the next build picks it up automatically.
zmk-workspace
├── config/ # keymap files and west.yml
├── draw/ # keymap-drawer config and rendered layouts
├── build.yaml # build targets
├── flake.nix # the nix build environment
├── Justfile # command definitions (`just` without args lists recipes)
│
├── firmware/ # ─── generated by `just build`: compiled firmware ends up here ───
├── modules/ # ─── generated by `just init` ────────────────────────────────────
├── zephyr/
└── zmk/ # ─────────────────────────────────────────────────────────────────
AGENTS.md maps out which file to touch for which kind of change.
Pinning ZMK to a release shields a working config from upstream changes, but the slow release
cadence means missing out on new features and fixes for long stretches of time. Tracking main has
the opposite trade-off: the latest features, but a build that may break from one day to the next.
This repo aims for the best of both worlds using pin-west: every
dependency is pinned to an exact commit, making builds reproducible, while the config keeps tracking
main.
config/west.ymlrecords each pin together with the branch it tracks and the date it was taken — including Zephyr, which ZMK's own manifest would otherwise pull by branch.just bump-westrefreshes every pin to the latest revision of its tracked branch, in one command.- A scheduled workflow does the same weekly and opens a PR, so updates only land once they build green.
As a bonus, the manifest restricts Zephyr's imports to what ZMK actually needs, avoiding the download of several GBs of unused Zephyr modules. (If you build for less common hardware, you may need to extend the allowlist.)
In the cloud. Push your changes and download the firmware from the repository's Actions tab as
usual — no local setup required. (GitHub disables Actions on newly created forks; enable them once
from the fork's Actions tab before the first push.) The build runs on the same nix environment as
the local setup, which keeps both in lockstep and avoids maintaining a second toolchain definition.
The stock ZMK workflow is kept around for reference and remains available via manual dispatch.
Locally. For faster iteration, the repo ships a nix-powered build environment that sets itself up automagically when you enter the workspace and stays completely isolated from the rest of your system. Once a few prerequisites are in place, the entire setup is:
cd zmk-workspace
direnv allow # sets up the build environment
just init # bootstraps the workspace from config/west.ymlAfter this initial setup, entering the workspace automatically activates the build environment. From
there, just build all compiles every target in build.yaml, just flash <target> flashes non-UF2
boards, and just draw re-renders the keymap images. Running just without arguments prints the
complete list of available recipes.
See docs/build-env.md for a setup guide and a detailed recipe reference. The
environment itself is continuously
tested on Linux and macOS.
(Click here for
a breakdown by layer – powered by keymap-drawer.)
All boards share the same 34-key base keymap: each <board>.keymap is a small adapter that places
the 34 shared positions onto the physical layout, padding larger boards like the Glove80 with extra
keys.
The keymap consists of six layers on top of a Colemak-DH base:
| Layer | Access | Contents |
|---|---|---|
| Base | default | Colemak-DH with homerow mods |
| Nav | hold left thumb (Space) | Arrow cluster (hold for word/page navigation), Alt-Tab swapper, Alt-F4 shortcut, CANCEL all layers and smart-modes |
| Fn | hold left thumb (Return) | F-keys (mirroring the numpad layout), media controls, desktop shortcuts |
| Num | tap Smart-Num for Numword, hold for momentary | Numpad on the left half |
| Sys | Num + Fn (must hold Num first) | Bluetooth profiles, bootloader, reset |
| Mouse | combo W + P (auto-toggles off) | Mouse movement, scrolling and buttons on the right half |
Symbols are mostly on combos instead of a dedicated layer, and a leader key provides access to Umlauts, Greek letters and system commands without reserving any keys.
Homerow mods (aka "HRMs") can be a game changer —
at least in theory. In practice, they require some finicky timing: In its most naive implementation,
in order to produce a "mod", they must be held longer than tapping-term-ms. In order to produce
a "tap", they must be held less than tapping-term-ms. This requires very consistent typing
speeds that, alas, I do not possess. Hence my quest for a "timer-less" HRM setup.
After months of tweaking, I eventually ended up with an HRM setup that is essentially timer-less, resulting in virtually no misfires.1 Yet it provides a fluent typing experience with mostly no delays.
One way to make HRMs effectively timer-less is to set tapping-term-ms to an extremely large value,
say 5 seconds. This removes the need for quick timing decisions, but it introduces two issues: (1)
To trigger a mod, you'd need to hold the HRM keys for what feels like an eternity. (2) During normal
typing, there's a noticeable delay between pressing a key and seeing it appear on the screen.2 To
address these, I use positive and negative exceptions that short-circuit the tapping term in most
scenarios.
- Specifically, to address the activation delay, I use ZMK's
balancedflavor, which produces a "hold" if another key is both pressed and released within the tapping-term. Because that's exactly what I normally do with HRMs, there's virtually never a need to wait past my long tapping term (see below for two exceptions). - To address the typing delay, I use ZMK's
require-prior-idle-msproperty, which immediately resolves an HRM as a "tap" when it's pressed shortly after another key has been tapped. This all but completely eliminates the delay.
This is great but there are still a few rough edges:
- When rolling keys, I sometimes unintentionally end up with "nested" key sequences:
key1down,key2down and up,key1up. Because of thebalancedflavor, this would falsely registerkey1as a mod. As a remedy, I use ZMK's "positional hold-tap" feature to force HRMs to always resolve as "tap" when the next key is on the same side of the keyboard. Problem solved. - ... or at least almost. By default, positional-hold-tap performs the positional check when the
next key is pressed. This is not ideal, because it prevents combining multiple modifiers on the
same hand. To fix this, I use the
hold-trigger-on-releasesetting, which delays the positional-hold-tap decision until the next key's release. With this, mods can be combined when held while positional hold-tap continues to work as expected when keys are tapped. - So far, nothing of the configuration depends on the duration of
tapping-term-ms. In practice, there are two reasons why I don't set it to infinity:- Sometimes, in rare circumstances, I want to combine a mod with an alpha-key on the same hand
(e.g., when using the mouse with the other hand). My positional hold-tap configuration
prevents this within the tapping term. By setting the tapping term to something large but
not crazy large (I use 280ms), I can still use same-hand
mod+alphashortcuts by holding the mod for just a little while before tapping the alpha-key. - Sometimes, I want to press a modifier without another key (e.g., on Windows, tapping
Winopens the search menu). Because thebalancedflavor only kicks in when another key is pressed, this also requires waiting pasttapping-term-ms.
- Sometimes, in rare circumstances, I want to combine a mod with an alpha-key on the same hand
(e.g., when using the mouse with the other hand). My positional hold-tap configuration
prevents this within the tapping term. By setting the tapping term to something large but
not crazy large (I use 280ms), I can still use same-hand
- Finally, it is worth noting that this setup works best in combination with a dedicated shift for
capitalization during normal typing (I like sticky-shift on a home-thumb). This is because
shifting alphas is the one scenario where pressing a mod may conflict with
require-prior-idle-ms, which may result in false negatives for fast typers.
Here's my configuration — aside from the zmk-helpers syntax sugar, it works with plain upstream ZMK.
#include "zmk-helpers/key-labels/36.h" // Source key-labels.
#define KEYS_L LT0 LT1 LT2 LT3 LT4 LM0 LM1 LM2 LM3 LM4 LB0 LB1 LB2 LB3 LB4 // Left-hand keys.
#define KEYS_R RT0 RT1 RT2 RT3 RT4 RM0 RM1 RM2 RM3 RM4 RB0 RB1 RB2 RB3 RB4 // Right-hand keys.
#define THUMBS LH2 LH1 LH0 RH0 RH1 RH2 // Thumb keys.
/* Left-hand HRMs. */
ZMK_HOLD_TAP(hml,
flavor = "balanced";
tapping-term-ms = <280>;
quick-tap-ms = <175>;
require-prior-idle-ms = <150>;
bindings = <&kp>, <&kp>;
hold-trigger-key-positions = <KEYS_R THUMBS>;
hold-trigger-on-release;
)
/* Right-hand HRMs. */
ZMK_HOLD_TAP(hmr,
flavor = "balanced";
tapping-term-ms = <280>;
quick-tap-ms = <175>;
require-prior-idle-ms = <150>;
bindings = <&kp>, <&kp>;
hold-trigger-key-positions = <KEYS_L THUMBS>;
hold-trigger-on-release;
)Hopefully, the above configuration "just works". If it doesn't, here's a few smaller (and larger) things to try.
- Noticeable delay when tapping HRMs: Increase
require-prior-idle-ms. As a rule of thumb, you want to set it to at least10500/xwherexis your (relaxed) WPM for English prose.3 - False negatives (same-hand): Reduce
tapping-term-ms(or disablehold-trigger-key-positions) - False negatives (cross-hand): Reduce
require-prior-idle-ms(or set flavor tohold-preferred— to continue usinghold-trigger-on-release, you must apply this patch to ZMK) - False positives (same-hand): Increase
tapping-term-ms - False positives (cross-hand): Increase
require-prior-idle-ms(or set flavor totap-preferred, which requires holding HRMs past tapping term to activate)
I am a big fan of combos for all sorts of things. In terms of comfort, I much prefer them over accessing layers that involve lateral thumb movements to be activated, especially when switching between layers in rapid succession.
One common concern about overloading the layout with combos is that they lead to misfires.
Fortunately, the above-mentioned require-prior-idle-ms option also works for combos, which in my
experience all but completely eliminates misfires — even when rolling keys on the home row!
My combo layout aims to place the most used symbols in easy-to-access locations while also making them easy to remember. Specifically:
- the top vertical-combo row replicates the symbols on a standard numbers row (except
+and&being swapped) - the bottom vertical-combo row is symmetric to the top row (subscript
_aligns with superscript^; minus-aligns with+; division/aligns with multiplication*; logical-or|aligns with logical-and&) - parenthesis, braces, brackets are set up symmetrically as horizontal combos with
<,>,{and}being accessed from the Navigation layer (or when combined withShift) - left-hand side combos for
tab,esc,cut,copyandpastethat go well with right-handed mouse usage
All combos are defined in
config/combos.dtsi.
Two layers turn themselves off again once you're done with them, so they never need an explicit exit.
Inspired by Jonas Hietala's Numword for QMK, I implemented my own Auto-layer behavior for ZMK to set up Numword. It is triggered via a single tap on "Smart-Num". Numword continues to be activated as long as I type numbers, and deactivates automatically on any other keypress (holding it activates a non-sticky num layer).
After using Numword for more than a year now, I have been overall very happy with it. When typing single digits, it effectively is a sticky-layer but with the added advantage that I can also use it to type multiple digits.
The main downside is that if a sequence of numbers is immediately followed by any of the letters
on which my numpad is located (WFPRSTXCD), then the automatic deactivation won't work. But this is
rare — most number sequences are terminated by space, return or some form of
punctuation/delimination. To deal with the rare cases where they aren't, there is a CANCEL key on
the navigation-layer that deactivates Numword, Capsword and Smart-mouse. (It also toggles off when
pressing Numword again, but I find it cognitively easier to have a dedicated "off-switch" than
keeping track of which modes are currently active.)
Similarly to Numword, I have a smart-mouse layer (activated by comboing W + P), which replaces the navigation cluster with scroll and mouse-movements, and replaces the right thumbs with mouse buttons. Pressing any other key automatically deactivates the layer.
The remaining features all follow the same idea: rather than spending scarce keys, give existing ones a second job that is unambiguous in context.
My right thumb triggers three variations of shift as well as repeat: Tapping after any alpha key yields key-repeat (to reduce SFUs). Tapping after any other keycode yields sticky-shift (used to capitalize alphas). Holding activates a regular shift, and double-tapping (or equivalently shift + tap) activates ZMK's Caps-word behavior.
One minor technical detail: While it would be possible to implement the double-tap functionality as a tap-dance, this would add a delay when using single taps. To avoid the delays, I instead implemented the double-tap functionality as a mod-morph.
To economize on keys, I am using hold-taps on my navigation cluster, which yield home, end,
begin/end of document, and delete word forward/backward on long-presses. The exact
implementation is tweaked so that Ctrl is silently absorbed in combination with home and end
to avoid accidental document-wide operations (which are accessible via the dedicated begin/end document keys.)
I am using Nick Conway's fantastic
tri-state behavior, implemented as a module, for
a one-handed Alt-Tab switcher (PWin and NWin).
I am using my own implementation of a Leader key
(activated by comboing S + T) to bind various behaviors to my layout without
reserving dedicated keys. Currently, I am using them to bind German Umlauts, Greek letters for math
usage, and various system commands (e.g., to toggle Bluetooth). See
leader.dtsi for the full list
of leader key sequences.
Since I switched from QMK to ZMK I have been very impressed with how easy it is to set up relatively complex layouts in ZMK. For the most part I don't miss any functionality (to the contrary, I found that ZMK supports many features natively that would require complex user-space implementations in QMK). Below are a few remaining issues:
- ZMK does not yet support "tap-only" combos (#544), requiring a brief pause when wanting to chord HRMs that overlap with combo positions. As a workaround, I implemented all homerow combos as homerow-mod-combos. This is good enough for day-to-day, but does not address all edge cases (eg changing active mods).
- Very minor:
&bootloaderdoesn't work with stm32 boards like the Planck (#1086)
This config is written for my own keyboards. If you are looking to adopt it, here are the main steps:
- Fork the repository on GitHub or copy parts of it. GitHub disables Actions on new forks — enable
them once from the fork's
Actionstab if you want to build in the cloud. - Adjust
build.yamlto list your boards and shields. - Add a
<board>.keymapadapter and<board>.conffor your keyboard —AGENTS.mdhas the step-by-step recipe, including how to find or write the key-position labels for a new board. - Edit
config/base.keymap(andcombos.dtsi,leader.dtsi,mouse.dtsi) to taste. Features are self-contained: e.g., if you don't want the leader key or the mouse layer, delete the#includeand the corresponding entries inwest.yml. - Build the firmware — either by pushing to GitHub and letting the cloud build do the work (no local setup needed), or with the local build environment.
The customization instructions in AGENTS.md serve double duty: they brief coding
agents on the repo's conventions, and they read just as well as a human-readable how-to.
- The official ZMK documentation — the reference for everything upstream: behaviors, keycodes, configuration and the build system.
- The collection of ZMK modules used in this configuration.
- pin-west, the manifest-locking tool used to pin the firmware. It works with any west workspace, not just this one.
- A ZMK-centric introduction to Git. While the original focus on maintaining ZMK forks is less relevant since ZMK has become modular, its lessons are generally applicable to any Git repo.
Footnotes
-
I call it "timer-less", because the large tapping-term makes the behavior insensitive to the precise timings. One may say that there is still the
require-prior-idletimeout. However, with both a large tapping-term and positional-hold-taps, the behavior is not actually sensitive to therequire-prior-idletiming: All it does is reduce the delay in typing. ↩ -
The delay is determined by how quickly a key is released and is not directly related to the tapping-term. But regardless of its duration, most people still find it noticeable and disruptive. ↩
-
E.g, if your WPM is 70 or larger, then the default of 150ms (=10500/70) should work well. The rule of thumb is based on an average character length of 4.7 for English words. Taking into account 1 extra tap for
space, this yields a minimumrequire-prior-idle-msof (60 * 1000) / (5.7 * x) ≈ 10500 / x milliseconds. The approximation errs on the safe side, as in practice home row taps tend to be faster than average. ↩