Caps Lock as a super key on macOS with Hammerspoon

· Tech

I use a lot of terminal tools (Helix, tmux, lazygit) alongside desktop apps like Safari, Ghostty, and Slack. Switching between them meant either reaching for awkward key combos or installing heavyweight apps like Karabiner-Elements or Raycast just for hotkeys.

I wanted one key that does three things:

And a bonus: when Ghostty is focused, hold Caps Lock acts as Ctrl instead of app switching. This means Caps Lock + Space sends Ctrl+Space, which is my tmux prefix, without moving my hand off the home row.

The solution: two lightweight tools that are already on or trivially added to macOS.

This post is part of my terminal workflow series.

Why hidutil

hidutil is built into macOS. It remaps keys at the IOKit level, before any app sees them. No installation, no background process eating RAM, no GUI. One command remaps Caps Lock to F18, a key that physically does not exist on Apple keyboards, so there are zero conflicts with anything.

Why Hammerspoon

Hammerspoon is a lightweight (~30MB) scripting bridge for macOS automation. It uses Lua. The entire configuration is a single init.lua file. It intercepts the F18 key events and implements the tap / double-tap / hold logic.

Why not Karabiner? Karabiner is a kernel-level input rewriter with a complex JSON config format. It is a great tool but massive overkill for remapping one key. Why not Raycast? Raycast can do Hyper Key + app switching, but it is a full productivity suite when all I needed was hotkeys. Hammerspoon does exactly what I need and nothing more. And if I ever want window management or other automation, I add a few lines of Lua instead of installing another app.

Setup

Prerequisites

Install Hammerspoon:

Terminal window
brew install --cask hammerspoon

Step 1: Remap Caps Lock to F18 with hidutil

Run once to test:

Terminal window
hidutil property --set '{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":0x700000039,"HIDKeyboardModifierMappingDst":0x70000006D}]}'

To persist across restarts, create a LaunchAgent. Save this as ~/Library/LaunchAgents/com.local.capslock-remap.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.local.capslock-remap</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/hidutil</string>
<string>property</string>
<string>--set</string>
<string>{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":0x700000039,"HIDKeyboardModifierMappingDst":0x70000006D}]}</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>

Load it:

Terminal window
launchctl load ~/Library/LaunchAgents/com.local.capslock-remap.plist

Verify it is working:

Terminal window
hidutil property --get "UserKeyMapping"

You should see a mapping from 30064771129 to 30064771181.

Step 2: Hammerspoon config

Save this as ~/.hammerspoon/init.lua:

-- =============================================================
-- Caps Lock (remapped to F18 via hidutil) - triple behavior:
-- tap - Escape
-- double tap - toggle Caps Lock
-- hold + key - switch app (or Ctrl in Ghostty)
-- =============================================================
-- App mappings (hold Caps Lock + letter outside Ghostty)
Apps = {
s = "Safari",
f = "Firefox",
c = "Google Chrome",
g = "Ghostty",
l = "Slack",
i = "Signal",
e = "Mail",
}
-- Config
DOUBLE_TAP_WINDOW = 0.25
F18 = 79
-- State (global so recovery timer and console can access them)
CapsDown = false
UsedAsModifier = false
LastTapTime = 0
PendingEscape = nil
-- Caps Lock indicator
local function showCapsState(on)
hs.alert.closeAll()
hs.alert.show(on and "CAPS ON" or "caps off", 0.6)
end
-- Key down handler
DownTap = hs.eventtap.new({ hs.eventtap.event.types.keyDown }, function(e)
if e:getKeyCode() == F18 then
CapsDown = true
UsedAsModifier = false
return true
end
if CapsDown then
local frontApp = hs.application.frontmostApplication()
local isGhostty = frontApp and frontApp:name() == "Ghostty"
if isGhostty then
UsedAsModifier = true
hs.eventtap.keyStroke({"ctrl"}, e:getKeyCode(), 0)
return true
end
local char = hs.keycodes.map[e:getKeyCode()]
if char and Apps[char] then
UsedAsModifier = true
hs.application.launchOrFocus(Apps[char])
return true
end
end
return false
end)
-- Key up handler
UpTap = hs.eventtap.new({ hs.eventtap.event.types.keyUp }, function(e)
if e:getKeyCode() ~= F18 then
return false
end
CapsDown = false
if UsedAsModifier then
return true
end
local now = hs.timer.secondsSinceEpoch()
local delta = now - LastTapTime
LastTapTime = now
if PendingEscape then
PendingEscape:stop()
PendingEscape = nil
end
if delta < DOUBLE_TAP_WINDOW then
hs.hid.capslock.toggle()
showCapsState(hs.hid.capslock.get())
LastTapTime = 0
else
PendingEscape = hs.timer.doAfter(DOUBLE_TAP_WINDOW, function()
hs.eventtap.keyStroke({}, "escape")
PendingEscape = nil
end)
end
return true
end)
-- Start
DownTap:start()
UpTap:start()
-- ── Auto-recover eventtaps ──────────────────────────────────
local function restartTaps()
if DownTap then DownTap:stop() end
if UpTap then UpTap:stop() end
hs.timer.doAfter(0.5, function()
DownTap:start()
UpTap:start()
end)
end
local function ensureTapsRunning()
if not DownTap:isEnabled() or not UpTap:isEnabled() then
restartTaps()
end
end
-- Restart on wake / unlock
CaffeinateWatcher = hs.caffeinate.watcher.new(function(event)
if event == hs.caffeinate.watcher.systemDidWake
or event == hs.caffeinate.watcher.screensDidUnlock then
hs.timer.doAfter(1, restartTaps)
end
end)
CaffeinateWatcher:start()
-- Safety net: check every 30s
RecoveryTimer = hs.timer.doEvery(30, ensureTapsRunning)
hs.alert.show("Caps Lock remapped", 1)

All state variables are global so they are accessible from the Hammerspoon console and the recovery timer. The auto-recovery section at the bottom handles a common annoyance: macOS sometimes silently kills eventtaps after sleep, screen unlock, or system updates. A caffeinate.watcher restarts the taps on wake and unlock, and a 30-second timer acts as a safety net in case they die for any other reason.

Step 3: Enable Accessibility

This is required. Without it, Hammerspoon eventtap cannot intercept key events and the whole thing silently fails.

Go to System Settings > Privacy & Security > Accessibility and enable the toggle for Hammerspoon. If it was already enabled and things are not working, turn it off and on again, then Reload Config in Hammerspoon.

Step 4: Launch at login

Open Hammerspoon preferences and enable Launch Hammerspoon at login. Without this, the Caps Lock behavior will not work after a restart until you manually open Hammerspoon.

Step 5: Reload

Open Hammerspoon and hit Reload Config (or press Cmd+R). You should see a brief “Caps Lock remapped” alert on screen.

Result

One key, context-aware behavior, two minimal tools, zero bloat:

ActionContextResult
Tap Caps LockAnywhereEscape
Double tap Caps LockAnywhereToggle Caps Lock on/off
Hold Caps Lock + SOutside GhosttySafari
Hold Caps Lock + FOutside GhosttyFirefox
Hold Caps Lock + COutside GhosttyChrome
Hold Caps Lock + GOutside GhosttyGhostty
Hold Caps Lock + LOutside GhosttySlack
Hold Caps Lock + IOutside GhosttySignal
Hold Caps Lock + EOutside GhosttyMail
Hold Caps Lock + any keyInside GhosttyCtrl + that key

The Ghostty behavior means Caps Lock + Space sends Ctrl+Space, which is my tmux prefix. Caps Lock + A, Caps Lock + C, etc. all send their Ctrl equivalents. Useful for tmux, Helix, and anything else running in the terminal.

To add more apps, edit the Apps table in init.lua and reload. That is it.

Troubleshooting

Caps Lock types F18 / does nothing: Check that the hidutil mapping is active with hidutil property --get "UserKeyMapping". If empty, re-run the hidutil property --set command or reload the LaunchAgent.

Keys pass through (typing letters instead of switching apps): Hammerspoon likely does not have Accessibility permissions. Check System Settings > Privacy & Security > Accessibility.

Stops working after sleep/unlock: The config auto-recovers eventtaps on wake and screen unlock. If it still fails, open the Hammerspoon console and check DownTap:isEnabled() and UpTap:isEnabled(). If either returns false, the recovery timer should pick it up within 30 seconds, or you can run DownTap:start(); UpTap:start() manually.

Want to verify the keycode? Open Hammerspoon Console and run:

hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(e) print("keycode: " .. e:getKeyCode()) return false end):start()

Press Caps Lock. It should print keycode: 79. If it prints something else, update the F18 variable in init.lua to match.

File locations

~/Library/LaunchAgents/com.local.capslock-remap.plist # hidutil remap
~/.hammerspoon/init.lua # Hammerspoon config

Both are good candidates for your dotfiles repo.