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?✨