Skip to content

Repository files navigation

urob's zmk-config

Firmware Environment

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.

Highlights

Toolchain

Keymap

If you are looking to adapt this config for your own keyboard, Part III gives some pointers.

Part I: The workspace

Top-down workspace

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 init to set up a local workspace mirroring the cloud build. zmk/, zephyr/ and modules/ are generated — delete them and just init recreates 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 the firmware

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.yml records 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-west refreshes 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.)

Building the firmware

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

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

Part II: The keymap

Keymap layout
(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.

Layers at a glance

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.

Timeless homerow mods

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 balanced flavor, 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-ms property, 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: key1 down, key2 down and up, key1 up. Because of the balanced flavor, this would falsely register key1 as 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-release setting, 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:
    1. 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 + alpha shortcuts by holding the mod for just a little while before tapping the alpha-key.
    2. Sometimes, I want to press a modifier without another key (e.g., on Windows, tapping Win opens the search menu). Because the balanced flavor only kicks in when another key is pressed, this also requires waiting past tapping-term-ms.
  • 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;
)

Troubleshooting

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 least 10500/x where x is your (relaxed) WPM for English prose.3
  • False negatives (same-hand): Reduce tapping-term-ms (or disable hold-trigger-key-positions)
  • False negatives (cross-hand): Reduce require-prior-idle-ms (or set flavor to hold-preferred — to continue using hold-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 to tap-preferred, which requires holding HRMs past tapping term to activate)

Using combos instead of a symbol layer

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 with Shift)
  • left-hand side combos for tab, esc, cut, copy and paste that go well with right-handed mouse usage

All combos are defined in config/combos.dtsi.

Smart layers

Two layers turn themselves off again once you're done with them, so they never need an explicit exit.

Numword

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

Smart-mouse

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.

Multi-purpose keys

The remaining features all follow the same idea: rather than spending scarce keys, give existing ones a second job that is unambiguous in context.

Magic Repeat/Shift/Capsword

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.

Navigation cluster

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

Swapper

I am using Nick Conway's fantastic tri-state behavior, implemented as a module, for a one-handed Alt-Tab switcher (PWin and NWin).

Leader key

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.

Known issues and workarounds

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: &bootloader doesn't work with stm32 boards like the Planck (#1086)

Part III: Adapting this config

This config is written for my own keyboards. If you are looking to adopt it, here are the main steps:

  1. Fork the repository on GitHub or copy parts of it. GitHub disables Actions on new forks — enable them once from the fork's Actions tab if you want to build in the cloud.
  2. Adjust build.yaml to list your boards and shields.
  3. Add a <board>.keymap adapter and <board>.conf for your keyboard — AGENTS.md has the step-by-step recipe, including how to find or write the key-position labels for a new board.
  4. Edit config/base.keymap (and combos.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 #include and the corresponding entries in west.yml.
  5. 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.

Related resources

  • 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

  1. 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-idle timeout. However, with both a large tapping-term and positional-hold-taps, the behavior is not actually sensitive to the require-prior-idle timing: All it does is reduce the delay in typing.

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

  3. 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 minimum require-prior-idle-ms of (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.

About

Personal ZMK firmware configuration for various boards (34-keys, Glove80, Planck)

Topics

Resources

Stars

1.4k stars

Watchers

24 watching

Forks

Used by

Contributors

Languages