A keyboard-first macOS with Karabiner-Elements and yabai
Every window command stays on the home row through a caps lock hyper key, chords, and vi mode, with one config language to debug
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.
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:
| Key | Opens | Key | Opens |
|---|---|---|---|
| t | iTerm | c | Calendar |
| s | Safari | a | Activity Monitor |
| f | Firefox | d | Disk Utility |
| v | VS Code | i | Music |
| m | Spark | 1 2 3 | OneNote, 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" } ] }jsonThe 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
jandkwithin 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+esccycles windows (command+backtick),command+shift+esccycles in reverse, and plainshift+esctypes 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" }
]
}jsonOption-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:
| Keys | yabai command |
|---|---|
| opt + h j k l | focus west / south / north / east |
| opt + p / n | focus previous / next, stack-aware with fallbacks |
| opt + shift + p / n | focus within the stack |
| opt + shift + h j k l | swap with the window in that direction |
| cmd + shift + h j k l | warp the window in that direction |
| opt + shift + w a s d | grow the window |
| cmd + shift + w a s d | shrink the window |
| opt + ctrl + arrows | insert west / south / north / east |
| opt + ctrl + h j k l | stack in that direction |
| opt + r / x / y | rotate 90, mirror y-axis, mirror x-axis |
| opt + d / f | zoom-parent / zoom-fullscreen |
| opt + shift + f | native fullscreen |
| opt + cmd + 0 | balance the tree |
| opt + ctrl + space | toggle float |
| shift + ctrl + z / x / c | send window to previous / recent / next space |
| shift + ctrl + 1..0 | send 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.