An Engineer's Blog

Back

A keyboard-first macOS with Karabiner-Elements and yabaiBlur image

Overview#

My Mac has no window management muscle memory that involves the mouse. The tiling window manager is yabai ↗ in BSP mode, and every command reaches it through Karabiner-Elements ↗. Most yabai setups pair the WM with skhd for hotkeys; mine does not. Karabiner is the only hotkey layer, which means one config language, one rule system, and one place to look when a key stops doing what I expect.

The setup replaced BetterTouchTool ↗, which used to own my window snapping and custom shortcuts, and it retired the scripting layers from earlier experiments: Hammerspoon’s Lua engine and Übersicht. The rest of this post is the setup as it stands: the hyper key, the vi and mouse modes, and the full key map between Karabiner and yabai.

From keycaps to window commands

The complex_modifications layer#

All the interesting behavior lives in ~/.config/karabiner/assets/complex_modifications/*.json: twelve hand-edited files, each a self-contained rule bundle that the Karabiner GUI imports. Edits here survive every GUI rewrite.

Per-device settings handle the smaller jobs alongside the rules. Two external keyboards swap command and option, because both are Windows-layout boards and muscle memory should not care which machine I am typing on. On the laptop, the built-in keyboard disables itself whenever that external board is connected. Four fn keys get back their macOS duties (mission control, launchpad, and the two illumination keys) for boards that ship without a media row.

The hyper key#

The anchor of the whole setup is caps lock. When pressed with any other key it becomes hyper: command+control+option+shift all at once. That single decision turns one dead key into a prefix with no competition, since no macOS app binds all four modifiers.

The rule comes from the Karabiner complex modifications library ↗. Real caps lock did not die; it moved: shift+caps_lock stays actual caps lock, which I press roughly never, and caps lock alone still holds caps lock, which the app launcher relies on for chording.

The hyper layer launches apps straight from the keyboard:

KeyOpensKeyOpens
tiTermcCalendar
sSafariaActivity Monitor
fFirefoxdDisk Utility
vVS CodeiMusic
mSpark1 2 3OneNote, Word, Excel

One subtlety: hyper is exactly the chord macOS uses for its hidden system diagnostics, so a fumbled launcher key can pop a wifi diagnostic utility in the middle of a demo. Three rules defuse that by remapping the dangerous chords to harmless keys:

{ "from": { "key_code": "period",
            "modifiers": { "mandatory": ["command", "shift", "option", "control"] } },
  "to": [ { "key_code": "f19" } ] }
json

The comma and w variants map to F18 and F17 the same way. Function keys themselves get help too: fn+1 through fn+12 emit F1 through F12 for the boards that lack a function row, and F3 through F6 remap to mission control, launchpad, and keyboard illumination.

Escape, quit, and punctuation#

Three small rules earn their keep daily:

  • jk escapes. Pressing j and k within 200 ms of each other, in any order, emits Escape. The hands almost never leave the home row, in the editor or anywhere else.
  • Esc is the backtick key. Escape with any modifier passes through as backtick with that modifier: command+esc cycles windows (command+backtick), command+shift+esc cycles in reverse, and plain shift+esc types a tilde. Escape sits in a better spot than the real backtick on most boards.
  • command-q needs a hold. The quit shortcut requires holding command-q briefly before the app quits. Between the command/option swap on external boards and how often I mangle chords, accidental quits were a weekly event; now they are impossible.

And one for the typists: semicolon and colon swap characters. Unshifted semicolon types :, shifted types ;. Code likes colons far more than prose likes semicolons, so the common character gets the home position.

Vi mode and mouse keys#

Two presets from the same library ↗ handle the cases where a key cannot just emit another key: Karabiner variables. In the Vi Mode rule, F+j pressed together latches vi mode, and until it clears, hjkl move the cursor, with variants for word-wise option-arrows and control+a/control+e. A companion chord latches visual mode, where the same movements carry shift and extend a selection.

Mouse Keys Mode v4 is the same idea for the pointer, using d as the mode key: d+hjkl moves the cursor, d+u/d+i/d+o click left, middle, and right, d+s toggles scroll wheel mode, and d+g/d+a drop or raise the speed to 0.4x and 2x. It rarely replaces the mouse, but for the occasional trackpad-unreachable click while my hands are on the keyboard, it is exactly right.

Karabiner as the yabai hotkey layer#

Here is the whole trick in one manipulator: a complex modification rule whose output is a shell command.

{
  "type": "basic",
  "from": {
    "key_code": "h",
    "modifiers": { "mandatory": ["option"] }
  },
  "to": [
    { "shell_command": "/opt/homebrew/bin/yabai -m window --focus west" }
  ]
}
json

Option-h focuses the window to the west. Sixteen rules of this shape, grouped in one yabai-karabiner.json file, cover every window operation I use:

Keysyabai command
opt + h j k lfocus west / south / north / east
opt + p / nfocus previous / next, stack-aware with fallbacks
opt + shift + p / nfocus within the stack
opt + shift + h j k lswap with the window in that direction
cmd + shift + h j k lwarp the window in that direction
opt + shift + w a s dgrow the window
cmd + shift + w a s dshrink the window
opt + ctrl + arrowsinsert west / south / north / east
opt + ctrl + h j k lstack in that direction
opt + r / x / yrotate 90, mirror y-axis, mirror x-axis
opt + d / fzoom-parent / zoom-fullscreen
opt + shift + fnative fullscreen
opt + cmd + 0balance the tree
opt + ctrl + spacetoggle float
shift + ctrl + z / x / csend window to previous / recent / next space
shift + ctrl + 1..0send window to space 1 through 10

The focus rules deserve a note. opt+n runs yabai -m window --focus stack.next || yabai -m window --focus next || yabai -m window --focus first, so the same key walks a stack, then the flat layout, then wraps around. Stacks stop being a special case.

On the yabai side, ~/.config/yabai/yabairc is deliberately boring: BSP layout, a 6 px gap with generous top and bottom padding, window_border on at 6 px so the active window is obvious, auto_balance on, and ctrl-drag for move, resize, and stack-by-dropping. The bulk of the file is a long manage=off list: menu-bar apps, dialogs, utilities, picture-in-picture windows, anything that tiles badly stays floating, below everything else. There are commented-out rules that used to pin apps to numbered spaces; the space assignments now live entirely in my head and the shift+ctrl bindings.

Costs and gotchas#

Every keypress in these rules spawns a shell. At human key rates that is invisible, but it is worth knowing that each option-h is a process, not a signal. The hand-edited rule files in assets/ are the safe layer to edit; the GUI only records that it imported them. Nothing in this stack needs a scripting engine: Hammerspoon and Übersicht from earlier setups are gone, because Karabiner already covers both the key remapping and the command dispatch.

One thing the move gave up: BetterTouchTool’s customization of the MacBook’s Touch Bar, the strip above the keyboard. I kept a quick compile button for Xcode there and used it plenty; Karabiner cannot touch that hardware, so the bar went back to Apple’s defaults.

Closing#

The practical change is that my hands stay on the home row from login to shutdown. Window arrangement used to mean trackpad grabs and drag-to-snap; accidental quits were routine; now both are gone, and window management is a folder of plain JSON I can actually read. Moving over from BetterTouchTool took one long evening, and it has paid that back every workday since.

A keyboard-first macOS with Karabiner-Elements and yabai
https://tin.ng/blog/2021-09-06--karabiner-yabai-tiling-macos
Author Tin Nguyen
Published at September 6, 2021
Comment seems to stuck. Try to refresh?✨