Back to posts
MacOS Keymap Guide

MacOS Keymap Guide

My full configuration of key mappings across MacOS, enabling a mostly keyboard focused experience.

Justin Wyne / November 22, 2023 / Updated March 22, 2026

Ever since I learned Vim in my college operating systems course, I've been hooked on keyboard efficiency. Vim handles text editing beautifully, but that desire for keyboard-only speed naturally expanded to the rest of my computing.

Over the years I went overboard trying to eliminate the mouse entirely; browser extensions like cvim, vimperator, and qutebrowser, emulating mouse movements through key presses, and more. But a mouse is unavoidable in a modern computing environment. After many attempts at overdoing it, I've settled on a practical balance between keyboard optimization and a handful of mouse bindings. Below are the results of that journey.

A word of warning: the time invested in building a setup like this almost certainly exceeds the time it saves. These configurations are also deeply personal; what works for my workflows may not suit yours. I've spent a decade tuning this because I genuinely enjoy it, not because it's efficient. If you go down this path, expect a rabbit hole.

Objective

A few principles guide the setup:

  • Portability: Everything is implemented in software, not hardware. I move between machines often and don't want to depend on a specific keyboard. See the Software section for more.
  • Home row first: Keep the most common shortcuts within reach of the home row to minimize finger travel.
  • Support both hand positions: Common tasks should work whether both hands are on the keyboard, or the left hand is on the keyboard and the right is on the mouse.
  • Avoid conflicts: Build on existing conventions rather than fighting them, and stay clear of app-specific shortcuts.

Software

Mechanical keyboards and their customization ecosystems (QMK/VIA) are appealing, but I move around a lot and don't want to depend on a specific keyboard. Everything here works on the built-in laptop keyboard.

  • Karabiner-Elements: the workhorse. Handles key remapping, modifier layers, and mouse button remapping.
  • Hammerspoon: scriptable macOS automation, used here for window management.
  • Raycast: a Spotlight replacement with a great plugin for switching Karabiner profiles.
  • yadm: dotfile manager for versioning and syncing all of the above across machines.
  • HomeRow: primarily for keyboard-based scrolling, though it also supports powerful OS-level click navigation.
  • JiTouch: trackpad gestures for tab navigation.

Basics

Caps Lock → Ctrl/Esc

The first thing I do on any new computer is remap caps lock to dual-purpose ctrl/esc; and also apply the same behavior to the physical left ctrl key itself.

  • Held with another key → ctrl
  • Pressed and released alone → esc

This means both caps lock and ctrl can fire escape, which is useful coming from Vim where you hit escape constantly.

Caps Lock → Control / Escape
Import this rule into Karabiner-Elements
Import
Karabiner modification 
1
{
2
"description": "⇪ Caps Lock → [ Control with other keys, ESC if pressed alone ]",
3
"manipulators": [
4
{
5
"from": {
6
"key_code": "caps_lock",
7
"modifiers": { "optional": ["any"] }
8
},
9
"to": [{ "key_code": "left_control" }],
10
"to_if_alone": [{ "key_code": "escape" }],
11
"type": "basic"
12
}
13
]
14
}

Vim Arrow Keys

With control in the right place, the next most impactful change is vim-style arrow keys on the home row. ctrl+hjkl is surprisingly conflict-free across most apps.

`1234567890-=deletetabQWERTYUIOP[]\ctrl / escASDFGHJKL;'returnshiftZXCVBNM,./shiftfnctrloptcmdspacecmdopt
KeyDescription
ctrl hLeft
ctrl jDown
ctrl kUp
ctrl lRight
Vim Arrow Keys
Import this rule into Karabiner-Elements
Import
karabiner.json 
1
{
2
"description": "Control` `h/j/k/l to Arrows",
3
"manipulators": [
4
5
"from": {
6
"key_code": "h",
7
"modifiers": {
8
"mandatory": ["control"],
9
"optional": ["caps_lock"]
10
}
11
},
12
"to": [
13
{
14
"key_code": "left_arrow"
15
}
16
],
17
"type": "basic"
18
},
19
20
"from": {
21
"key_code": "j",
22
"modifiers": {
23
"mandatory": ["control"],
24
"optional": ["caps_lock"]
25
}
26
},
27
"to": [
28
{
29
"key_code": "down_arrow"
30
}
31
],
32
"type": "basic"
33
},
34
35
"from": {
36
"key_code": "k",
37
"modifiers": {
38
"mandatory": ["control"],
39
"optional": ["caps_lock"]
40
}
41
},
42
"to": [
43
{
44
"key_code": "up_arrow"
45
}
46
],
47
"type": "basic"
48
},
49
50
"from": {
51
"key_code": "l",
52
"modifiers": {
53
"mandatory": ["control"],
54
"optional": ["caps_lock"]
55
}
56
},
57
"to": [
58
{
59
"key_code": "right_arrow"
60
}
61
],
62
"type": "basic"
63
}
64
]
65
}

Forward Delete

Press = and delete simultaneously to produce a forward delete. Laptop keyboards don't have a dedicated forward delete key, and reaching for fn+delete is awkward. This chord puts it in a reachable spot.

Application Launching

Hold ; and tap a letter to launch any of my most-used apps.

; works well as a modifier for a few reasons: it's easy to reach from the home row, it doesn't conflict with existing shortcuts, and it rarely rolls into another key in natural typing. If your chosen key commonly precedes another character, you'll get false triggers when typing quickly.

`1234567890-=deletetabQWYP[]\ctrl / escKL;'returnshiftZXCVB,./shiftfnctrloptcmdspacecmdopt
KeyDescription
Home Row
; aZoom
; sSlack
; dVS Code
; fFantastical
; gForklift
; hHome
; j1Password
; nNotes
Top Row
; eReminders
; rArc
; tTelegram
; iGhostty
; oObsidian
; uMusic
Bottom Row
; mMessages
Semicolon App Launcher
Import this rule into Karabiner-Elements
Import
 
1
{
2
"description": "Semicolon as modifier layer",
3
"manipulators": [
4
{
5
"from": {
6
"key_code": "semicolon"
7
},
8
"to": [
9
{
10
"set_variable": {
11
"name": "semicolon_modifier",
12
"value": 1
13
}
14
}
15
],
16
"to_after_key_up": [
17
{
18
"set_variable": {
19
"name": "semicolon_modifier",
20
"value": 0
21
}
22
}
23
],
24
"to_if_alone": [
25
{
26
"key_code": "semicolon"
27
}
28
],
29
"type": "basic"
30
},
31
{
32
"conditions": [
33
{
34
"name": "semicolon_modifier",
35
"type": "variable_if",
36
"value": 1
37
}
38
],
39
"from": {
40
"key_code": "h"
41
},
42
"to": [
43
{
44
"shell_command": "open -a Home"
45
}
46
],
47
"type": "basic"
48
},
49
{
50
"conditions": [
51
{
52
"name": "semicolon_modifier",
53
"type": "variable_if",
54
"value": 1
55
}
56
],
57
"from": {
58
"key_code": "m"
59
},
60
"to": [
61
{
62
"shell_command": "open -a Music"
63
}
64
],
65
"type": "basic"
66
},
67
{
68
"conditions": [
69
{
70
"name": "semicolon_modifier",
71
"type": "variable_if",
72
"value": 1
73
}
74
],
75
"from": {
76
"key_code": "t"
77
},
78
"to": [
79
{
80
"shell_command": "open -a Telegram"
81
}
82
],
83
"type": "basic"
84
},
85
{
86
"conditions": [
87
{
88
"name": "semicolon_modifier",
89
"type": "variable_if",
90
"value": 1
91
}
92
],
93
"from": {
94
"key_code": "r"
95
},
96
"to": [
97
{
98
"shell_command": "open -a Brave\\ Browser"
99
}
100
],
101
"type": "basic"
102
},
103
{
104
"conditions": [
105
{
106
"name": "semicolon_modifier",
107
"type": "variable_if",
108
"value": 1
109
}
110
],
111
"from": {
112
"key_code": "j"
113
},
114
"to": [
115
{
116
"shell_command": "open -a 1Password"
117
}
118
],
119
"type": "basic"
120
},
121
{
122
"conditions": [
123
{
124
"name": "semicolon_modifier",
125
"type": "variable_if",
126
"value": 1
127
}
128
],
129
"from": {
130
"key_code": "e"
131
},
132
"to": [
133
{
134
"shell_command": "open -a Reminders"
135
}
136
],
137
"type": "basic"
138
},
139
{
140
"conditions": [
141
{
142
"name": "semicolon_modifier",
143
"type": "variable_if",
144
"value": 1
145
}
146
],
147
"from": {
148
"key_code": "f"
149
},
150
"to": [
151
{
152
"shell_command": "open -a Fantastical"
153
}
154
],
155
"type": "basic"
156
},
157
{
158
"conditions": [
159
{
160
"name": "semicolon_modifier",
161
"type": "variable_if",
162
"value": 1
163
}
164
],
165
"from": {
166
"key_code": "d"
167
},
168
"to": [
169
{
170
"shell_command": "open -a Visual\\ Studio\\ Code\\ -\\ Insiders"
171
}
172
],
173
"type": "basic"
174
},
175
{
176
"conditions": [
177
{
178
"name": "semicolon_modifier",
179
"type": "variable_if",
180
"value": 1
181
}
182
],
183
"from": {
184
"key_code": "o"
185
},
186
"to": [
187
{
188
"shell_command": "open -a Obsidian"
189
}
190
],
191
"type": "basic"
192
},
193
{
194
"conditions": [
195
{
196
"name": "semicolon_modifier",
197
"type": "variable_if",
198
"value": 1
199
}
200
],
201
"from": {
202
"key_code": "i"
203
},
204
"to": [
205
{
206
"shell_command": "open -a iterm"
207
}
208
],
209
"type": "basic"
210
},
211
{
212
"conditions": [
213
{
214
"name": "semicolon_modifier",
215
"type": "variable_if",
216
"value": 1
217
}
218
],
219
"from": {
220
"key_code": "a"
221
},
222
"to": [
223
{
224
"shell_command": "open -a zoom.us"
225
}
226
],
227
"type": "basic"
228
},
229
{
230
"conditions": [
231
{
232
"name": "semicolon_modifier",
233
"type": "variable_if",
234
"value": 1
235
}
236
],
237
"from": {
238
"key_code": "s"
239
},
240
"to": [
241
{
242
"shell_command": "open -a slack"
243
}
244
],
245
"type": "basic"
246
}
247
]
248
}

Window Management

I've tried a lot of window management tools; tiling managers like Aerospace and Yabai, quadrant-based tools like SizeUp, and others. The only thing that's stuck is a simple grid-based Hammerspoon script. It handles the common cases without any ceremony, and it's flexible enough for one-offs.

Resizing windows

`1234567890-=deletetabQWERTYUIOP[]\ctrl / escASDFGHJKL;'returnshiftZXCVBNM,./shiftfnctrloptcmdspacecmdopt

Moving windows

`1234567890-=deletetabQWERTYUIOP[]\ctrl / escASDFGHJKL;'returnshiftZXCVBNM,./shiftfnctrloptcmdspacecmdopt
KeyDescription
ctrl cmd hShrink Left
ctrl cmd jGrow Down
ctrl cmd kShrink Up
ctrl cmd lGrow Right
KeyDescription
ctrl opt hNudge Left
ctrl opt jNudge Down
ctrl opt kNudge Up
ctrl opt lNudge Right
init.lua 
1
-- GRID
2
hs.window.animationDuration=0.2
3
local hotkey = require "hs.hotkey"
4
local grid = require "hs.grid"
5
6
grid.MARGINX = 20 grid.MARGINY = 20 grid.GRIDHEIGHT = 4 grid.GRIDWIDTH = 6
7
8
local mod_resize = {"ctrl", "cmd"} local mod_move = {"ctrl", "alt"}
9
10
-- Move Window hotkey.bind(mod_move, 'j', grid.pushWindowDown)
11
hotkey.bind(mod_move, 'k', grid.pushWindowUp) hotkey.bind(mod_move, 'h',
12
grid.pushWindowLeft) hotkey.bind(mod_move, 'l', grid.pushWindowRight)
13
14
-- Resize Window hotkey.bind(mod_resize, 'k', grid.resizeWindowShorter)
15
hotkey.bind(mod_resize, 'j', grid.resizeWindowTaller) hotkey.bind(mod_resize,
16
'l', grid.resizeWindowWider) hotkey.bind(mod_resize, 'h',
17
grid.resizeWindowThinner)
18

cmd shift [ and cmd shift ] already work universally for tab switching. For right-hand-on-mouse compatibility, I remap the mouse's forward/back buttons to those same shortcuts. I rarely use forward/back navigation, but switching tabs is constant.

Mouse ButtonRemapped to
Mouse Forwardcmd shift [
(move left one tab)
Mouse Backwardcmd shift ]
(move right one tab)
Mouse Buttons → Tab Switching
Import this rule into Karabiner-Elements
Import
 
1
{
2
"description": "Mouse forward/back to switch tabs",
3
"manipulators": [
4
{
5
"from": {
6
"modifiers": {},
7
"pointing_button": "button4"
8
},
9
"to": [
10
{
11
"key_code": "close_bracket",
12
"modifiers": ["command", "shift"]
13
}
14
],
15
"type": "basic"
16
},
17
{
18
"from": {
19
"modifiers": {},
20
"pointing_button": "button5"
21
},
22
"to": [
23
{
24
"key_code": "open_bracket",
25
"modifiers": ["command", "shift"]
26
}
27
],
28
"type": "basic"
29
}
30
]
31
}

Tab Key Modifier Layer

tab also acts as a modifier. Hold it and press h/l to switch tabs; same result as the mouse buttons, but without leaving the keyboard.

  • tab alone still sends tab
  • tab + h → previous tab
  • tab + l → next tab

Trackpad Gestures

JiTouch adds gesture recognition on top of the standard trackpad. "One-Fix" means one finger resting (anchoring) while another performs the gesture.

One-Fix Left-Tap

One-Fix Right-Tap

Two-Fix Double-Tap

GestureAction
One-Fix Left-TapPrevious tab (⇧⌘[)
One-Fix Right-TapNext tab (⇧⌘])
Two-Fix Index-Double-TapRefresh

The tab gestures mirror the mouse button remapping and keyboard Tab modifier. All three input methods produce the same result, so whichever hand position you're in, tab switching is always one motion away.

Zoom

In Zoom, the mouse forward/back buttons are remapped to mute/unmute audio and video instead of tab switching since you're rarely switching tabs during a call.

Mouse ButtonAction
Mouse ForwardToggle video (cmd shift v)
Mouse BackwardToggle audio (cmd shift a)

These mirror terminal readline bindings. The main change: ctrl+f and ctrl+b are mapped to word jumps (opt+arrow) instead of the macOS default of single-character moves, which I find far more useful.

`1234567890-=deletetabQWERTYUIOP[]\ctrl / escASDFGHJKL;'returnshiftZXCVBNM,./shiftfnctrloptcmdspacecmdopt
KeyDescription
ctrl aMove to beginning of line
(MacOS default)
ctrl eMove to end of line
(MacOS default)
ctrl fMove forward one word
(Overrides MacOS default of one char)
ctrl bMove backward one word
(Overrides MacOS default of one char)
ctrl wDelete backward one word
(Exclude terminals)
Text Navigation
Import this rule into Karabiner-Elements
Import
karabiner.json 
1
{
2
"description": "Control w/f/b - for text navigation",
3
"manipulators": [
4
{
5
"from": {
6
"key_code": "f",
7
"modifiers": { "mandatory": ["control"] }
8
},
9
"to": [
10
{
11
"key_code": "right_arrow",
12
"modifiers": ["option"]
13
}
14
],
15
"type": "basic"
16
},
17
{
18
"from": {
19
"key_code": "b",
20
"modifiers": { "mandatory": ["control"] }
21
},
22
"to": [
23
{
24
"key_code": "left_arrow",
25
"modifiers": ["option"]
26
}
27
],
28
"type": "basic"
29
},
30
{
31
"conditions": [
32
{
33
"bundle_identifiers": [
34
"^com\\.googlecode\\.iterm2$",
35
"^com\\.github\\.wez\\.wezterm$",
36
"^com\\.mitchellh\\.ghostty$",
37
"^com\\.microsoft\\.VSCode$"
38
],
39
"type": "frontmost_application_unless"
40
}
41
],
42
"from": {
43
"key_code": "w",
44
"modifiers": { "mandatory": ["control"] }
45
},
46
"to": [
47
{
48
"key_code": "delete_or_backspace",
49
"modifiers": ["option"]
50
}
51
],
52
"type": "basic"
53
}
54
]
55
}

Keyboard shortcuts for menu bar items are great for quick lookups like calendar, weather, system stats without switching away from your current app. The popup disappears as soon as you're done.

These shortcuts are set directly within each app's preferences (Fantastical, iStat Menus) or in System Settings → Keyboard → Keyboard Shortcuts → App Shortcuts for system-level ones.

`1234567890-=deletetabQWERTYUIOP[]\ctrl / escASDFGHJKL;'returnshiftZXCVBNM,./shiftfnctrloptcmdspacecmdopt
KeyDescription
ctrl cmd 7Fantastical Menu Bar
ctrl cmd 8iStat Combined Menu Bar
ctrl cmd 9iStat Calendar Menu Bar
ctrl cmd 0iStat Weather Menu Bar
ctrl cmd -iStat Battery Menu Bar
ctrl cmd =MacOS Notification Center
cmd ctrl iMacOS Expose
cmd ctrl oMacOS Show Desktop

Settings

Conclusion

This setup is personal and always evolving. If you have questions, suggestions, or a setup of your own, I'd love to hear about it.