Skip to content

How Capslock works: F18, an event tap, and a mapping it gives back

Caps Lock is a poor modifier as macOS delivers it. Capslock remaps it to F18, reads it from an active event tap, and changes only the part of the system key mapping it owns.

Caps Lock sits in the best spot on the keyboard and does the least useful job. Capslock gives it a new one: tap it for Escape, hold it for Hyper, and combine it with the four modifiers to reach sixteen control planes. This post explains how a native app does that on macOS without a kernel extension, a driver, or a privileged helper.

Caps Lock is not a modifier

macOS does not report Caps Lock as a key that goes down and comes back up. It reports a lock state that flips, and it adds a short delay so that brushing the key does not toggle it. That is fine for typing capitals and useless for a modifier that must be held.

So Capslock starts with a remap. At activation it asks hidutil to send the physical Caps Lock as F18, a key that no Apple keyboard has. F18 arrives as an ordinary key-down and key-up pair, which the app can treat as "held" and "released".

F18 has a second advantage: it is not one of the system modifiers. Holding Caps and Shift and pressing H arrives as F18 held, Shift held, H. Nothing in macOS combines F18 with Shift into something else, so all four modifiers stack on top of Caps for free.

Reading keys from an event tap

Capslock installs a CGEventTap at the HID level, at the head of the queue, as an active tap. Active means it can change or drop events, not just observe them. macOS gates that on the Accessibility permission. It does not need Input Monitoring, which only applies to listen-only taps.

If the permission is missing when the app starts, it keeps retrying. The tap installs the moment you grant access, without a relaunch. The tap runs on its own thread, and its callback does little more than compare integers, so typing does not slow down.

Resolving a chord

When F18 goes down the app records that Caps is held and swallows the event. When F18 comes back up it decides what the press was:

  • A tap, with no other key pressed, runs the tap action. In the default profile that is Escape.
  • A chord, Caps plus another key, runs the action mapped to that key on the current plane, and the original key never reaches the app in front.
  • A double tap, when a mapping defines one, runs that instead. The single tap waits for the double-tap window so the two cannot both fire.

The plane comes from the modifiers you hold with Caps. Each modifier is a bit: Command 1, Option 2, Control 4, Shift 8. Their sum names one of sixteen planes, from 0 for Caps alone to 15 for all four. Matching is exact. A chord with an extra modifier never falls back to a less specific plane, so adding Shift to a navigation key can mean "select" without surprising you elsewhere.

Every event Capslock synthesizes carries a marker, and the tap skips events that carry it. Without that, the arrow key Capslock sends for Caps+H would come straight back through the tap.

Two ways to be Hyper

Earlier Capslock layouts implemented Hyper by holding the four right-side modifiers. Launchers and window managers such as Alfred and Moom listen for that combination, and many people have shortcuts built on it.

Capslock keeps this as a choice rather than an implementation detail:

  • Classic Compatibility holds right Command, Option, Control and Shift for as long as Caps is down, so other apps see Hyper as before.
  • Native Clean keeps Hyper private. Other apps see nothing while Caps is held, only the actions it produces.

In both modes the plane is computed from the modifiers you physically press. The right-side keys Capslock holds for compatibility are excluded, or every chord would land on plane 15.

Owning only what it changed

hidutil property --set replaces the whole user key mapping. The obvious implementation writes its own list on start and clears the property on quit, which deletes any mapping another tool or the user had set. Capslock treats the existing mapping as somebody else's:

  1. It reads the current mapping and refuses to start if another entry already remaps Caps Lock.
  2. Before changing anything it writes a recovery journal with the previous mapping and the single entry it is about to add.
  3. It merges only its own entry and reads the result back to confirm it.
  4. On Pause or Quit it removes only that entry and restores the previous mapping, unless something else changed it in the meantime.

If another program changes the mapping while Capslock runs, Capslock stops and reports a conflict instead of fighting over the last write. It checks again after sleep and wake, when a keyboard is plugged in, and when the user session changes. This is also why Pause gives you a real Caps Lock back, not an F18 that is merely passed through.

When the app does not quit cleanly

A crash or a forced quit skips the clean-up in step 4. Two things cover that case. A small watchdog embedded in the app reads the recovery journal and watches its parent process; if the app exits without releasing the mapping, it restores the previous one. The next launch also reconciles any journal it finds before it installs the tap.

If you ever need to clear the mapping by hand, use the app's own command, which removes only the Caps Lock entry it added:

/Applications/Capslock.app/Contents/MacOS/Capslock --restore-original-caps-lock

Do not reset the mapping with an empty list. That is global and would remove every other tool's remapping too. The mapping does not survive a restart in any case.

What stays local

Everything above happens on your Mac. Capslock has no analytics or advertising SDK and does not send keys, mappings, clipboard contents, or usage data anywhere. Its diagnostic log leaves out typed text, clipboard contents, Shell output, and app history. The privacy page lists what is stored and when the app uses the network.