Config File
The Capslox keymap is defined in a single file: keymap.jsonc.
JSONC = JSON with Comments. You can add // line comments and /* */ block comments. (New to JSON? See MDN's quick intro.)
File location
| Platform | Path |
|---|---|
| macOS | ~/.config/capslox/keymap.jsonc |
| Windows | %USERPROFILE%\.config\capslox\keymap.jsonc |
Hot-reload: Save the file and changes apply instantly. No restart needed. If the file has syntax errors, Capslox shows an error dialog and keeps the previous config.
OS-specific override
If you sync your config directory across operating systems (e.g. via iCloud Drive or Dropbox), you can place an OS-specific override alongside the main file:
| Platform | Override file |
|---|---|
| macOS | keymap.macos.jsonc |
| Windows | keymap.windows.jsonc |
When Capslox starts, it uses the platform-specific file if it exists, otherwise it falls back to keymap.jsonc. This is useful when you manage your config directory across operating systems — for example via iCloud Drive, Dropbox, GNU Stow, or chezmoi — and need different bindings per platform.
Priority:
keymap.macos.jsonc/keymap.windows.jsonc>keymap.jsonc
Schema Overview
Top-level structure:
{
"version": "1.0",
"custom_modifiers": { "caps": { "alias": "cx", "tap": "esc" } },
"vars": {
"apps_emacs_keybindings": ["com.apple.Terminal", "com.googlecode.iterm2"]
},
"layers": [
{
"id": "base",
"on_miss": "pass_to_os",
"bindings": {
"cx-e": "up",
"cx-d": "down",
"cx-a": {
"action": "send_keys",
"keys": "opt-left",
"when": { "apps": { "$ref": "/vars/apps_emacs_keybindings" }, "action": "ctrl-[ b" }
}
}
}
]
}
A config has:
version— schema version string (e.g."1.0").custom_modifiers— optional. Maps physical keys to modifier aliases. The default config uses"cx"(short for Capslox). See Custom Modifiers.vars— optional. Define reusable values (arrays, strings) referenced elsewhere with{ "$ref": "/vars/name" }. Useful for app ID lists shared across multiple bindings.layers— array of layers. Each layer hasid, optionalname, optionalactivation,on_miss, andbindings.
Layers
A layer is a named group of bindings. Layers form a stack. When a key is pressed, the engine searches top-down: the first matching binding wins. When a layer has no match, its on_miss field decides what happens next.
Each layer is an object with these fields:
id(required) — unique layer identifier.bindings(required) — map of trigger keys to actions.on_miss(required) — miss behavior; see Miss Behavior.activation— how the layer activates. Base layers don't need this; manual layers use{ "type": "manual" }; auto-app layers use{ "type": "auto_app", "bundle_ids": [...] }. See the sections below.name— human-readable label, shown in UI surfaces like Preferences → Keymap.enabled— boolean, defaults totrue. Whenfalse, the engine skips this layer entirely, as if it didn't exist. Handy for temporarily disabling a layer without deleting it.
Base Layer
Always active. Cannot be pushed or popped at runtime. Identified by "id": "base" — no explicit activation field needed.
{
"id": "base",
"on_miss": "pass_to_os",
"bindings": {
"cx-h": "left",
"cx-j": "down",
"cx-k": "up",
"cx-l": "right"
}
}
Manual Layer
Activated by layer_push, layer_toggle, or layer_swap actions from bindings. Deactivated by layer_pop or the corresponding release event.
{
"id": "vim_normal",
"activation": { "type": "manual" },
"on_miss": "block",
"bindings": {
"h": "left",
"j": "down",
"k": "up",
"l": "right",
"i": { "action": "layer_pop", "target": "vim_normal" },
"esc": "continue"
}
}
In this example, on_miss is "block" — unbound keys are blocked, making it behave like Vim normal mode. The "esc": "continue" binding lets esc fall through to the next layer (bypassing the block).
AutoApp Layer
Activates automatically when a matching app gains focus. Deactivates when the app loses focus.
{
"id": "terminal",
"name": "Terminal Overrides",
"activation": {
"type": "auto_app",
"bundle_ids": ["com.apple.Terminal", "com.googlecode.iterm2"]
},
"on_miss": "continue",
"bindings": {
"cx-a": "ctrl-a",
"cx-e": "ctrl-e"
}
}
App identifiers
| Platform | Format | Example |
|---|---|---|
| macOS | Bundle ID | com.apple.Terminal |
| Windows | Executable name | WindowsTerminal.exe |
Wildcard matching: the * in an app identifier matches one or more dot-bounded path segments. "com.jetbrains.*" matches com.jetbrains.intellij and com.jetbrains.pycharm, but NOT com.jetbrainsxyz (because xyz isn't a separate segment). This dot-boundary rule applies everywhere apps lists are used, including when clauses on bindings.
Bindings
A binding maps a trigger key to one or more actions. Bindings live inside a layer's bindings object. The key is the trigger (using key syntax), the value is the action or binding type.
"bindings": {
"cx-h": "left",
"cx-j": "down",
"caps": { "type": "mod_tap", "tap": "esc" },
"space": {
"type": "long_press",
"short": "space",
"long": { "action": "layer_push", "target": "space_layer" },
"threshold_ms": 500
}
}
The binding value's "type" field declares the binding type (e.g. "type": "mod_tap", "type": "long_press"). When the binding type is "action" (a direct action — the most common case), the "action" field then names the specific action to perform (e.g. "action": "send_keys", "action": "layer_push").
Binding Value Forms
A binding value is, in full, an object. When type is "action", the object also carries an action field naming the action type.
Three equivalent forms
"type": "action" and "action": "send_keys" appear frequently across bindings. For convenience, Capslox lets you omit them — all three forms below produce the same binding:
"cx-h": { "type": "action", "action": "send_keys", "keys": "left" } // full form
"cx-h": { "action": "send_keys", "keys": "left" } // omit type (action is the implied binding type)
"cx-h": "left" // omit the whole object (send_keys is the implied action) — recommended
Mod Tap & Long Press
Trigger different actions based on how the key is pressed. The "type" field must be specified:
"caps": { "type": "mod_tap", "tap": "esc" }
"space": { "type": "long_press", "short": "space", "long": { "action": "layer_push", "target": "space_layer" } }
See Mod Tap and Long Press for details.
Directive keywords
Three special strings are not key syntax — they control per-key miss behavior within a layer:
"continue"— check the next layer (ignores this key's layeron_miss)"block"— swallow the key"pass_to_os"— skip remaining layers, send to OS
// In a layer with on_miss: "block", let Escape pass through to the next layer
"esc": "continue"
Which form to use
| Scenario | Form |
|---|---|
| Send key presses | "left" (string shorthand) |
| Pass through / block / send to OS | "continue" / "block" / "pass_to_os" (directive keywords) |
| Layer control, clipboard, window mgmt | { "action": "layer_toggle", ... } (action object) |
Need when condition |
Action object + when |
| Hold for modifier, tap for Esc | { "type": "mod_tap", ... } (key mode) |
| Long press to push layer | { "type": "long_press", ... } (key mode) |
Mod Tap
Hold for modifier, tap for action. Dual-purpose key.
Parameters:
tap— any action (string shorthand or object form).timeout_ms— tap validity window in milliseconds. Default: 200.
Example:
"caps": { "type": "mod_tap", "tap": "esc", "timeout_ms": 200 }
Resolution: Interrupt-based, not duration-based.
| Condition | Result |
|---|---|
| Release within timeout, no other key pressed | Tap — fires the tap action |
| Other key pressed while held (any time) | Hold — activates as modifier |
| Release after timeout, no other key pressed | Nothing — prevents accidental taps |
The hold modifier identity is inferred: if the key has a custom_modifiers entry, that alias is used. Otherwise, the physical key name itself becomes the modifier.
Long Press
Short press for one action, long press (hold past threshold) for another. Pure duration-based — no other key interaction needed.
Parameters:
short— action when released before threshold.long— action when held past threshold.threshold_ms— duration threshold in milliseconds. Default: 500.
Example:
"space": {
"type": "long_press",
"short": "space",
"long": { "action": "layer_push", "target": "space_layer" },
"threshold_ms": 500
}
When Clause
Make a binding behave differently based on the frontmost app. Add a when field to any direct binding.
// Single condition
"cx-a": {
"action": "send_keys",
"keys": "opt-left",
"when": {
"apps": ["com.apple.Terminal", "com.googlecode.iterm2"],
"action": "ctrl-[ b"
}
}
When the frontmost app matches one of the apps bundle IDs, the when action is used instead of the default action.
Using $ref for shared app lists
"cx-a": {
"action": "send_keys",
"keys": "opt-left",
"when": {
"apps": { "$ref": "/vars/apps_emacs_keybindings" },
"action": "ctrl-[ b"
}
}
This references the apps_emacs_keybindings array defined in the top-level vars object.
Multiple conditions
Checked in order, first match wins:
"cx-cmd-a": {
"action": "send_keys",
"keys": "opt-left*5",
"when": [
{
"apps": { "$ref": "/vars/apps_emacs_keybindings" },
"action": "(ctrl-[ b)*5"
},
{
"apps": ["com.jetbrains.*"],
"action": "alt-left*5"
}
]
}
Wildcards in apps use the same dot-boundary matching defined under AutoApp Layer.
If no when clause matches, the binding's default action is used.
Special when actions
Use "noop" as a when action to suppress a key in specific apps without letting it fall through:
"cx-i": {
"action": "send_keys",
"keys": "shift-up",
"when": {
"apps": { "$ref": "/vars/apps_emacs_keybindings" },
"action": "noop"
}
}
Relationship to AutoApp layers: when is for "same trigger key, different output per app." AutoApp layers are for "completely different binding set in a specific app." Both can coexist.
Miss Behavior
The on_miss field on each layer controls what happens when a key has no matching binding in that layer.
| Value | Behavior | Typical use |
|---|---|---|
"continue" |
Check the next layer in the stack (default) | Overlay layers (e.g. app-specific overrides) that handle a few keys and let the rest fall through |
"block" |
Block the key — it does nothing | Manually-activated layers (game maps, IDE command modes) where any unbound key should be swallowed to avoid mistakes |
"pass_to_os" |
Skip remaining layers, send the original key to the OS | The base layer, so unbound keys still reach the OS normally |
{
"id": "vim_normal",
"activation": { "type": "manual" },
"on_miss": "block",
"bindings": { ... }
}
Individual bindings can ignore the layer's on_miss for that key using directive keywords:
// Even though on_miss is "block", Escape falls through to the next layer
"esc": "continue"
// Force a specific key to pass directly to OS, bypassing all remaining layers
"f5": "pass_to_os"
See Binding Value Forms above for the full list of directive keywords.
Actions
Every binding triggers an action. Actions use the "action" field as the type discriminator. Actions appear on the value side of bindings and inside key mode fields (tap, short, long).
Available actions
Capslox General
| Action | Description |
|---|---|
show_preferences |
Open Capslox preferences |
edit_keymap |
Open the built-in keymap editor |
Layer switching
| Action | Description |
|---|---|
layer_push |
Push a layer (deactivates on trigger key release) |
layer_pop |
Pop a layer (or the top of stack) |
layer_toggle |
Toggle a layer on or off |
layer_swap |
Replace the top non-base layer with another |
Composition & control
| Action | Description |
|---|---|
shell_action |
Run a shell command |
action_sequence |
Execute multiple actions in order |
noop |
Suppress the key (do nothing) |
Input & state
| Action | Description |
|---|---|
send_keys |
Send key presses |
toggle_caps_lock |
Toggle Caps Lock on or off |
Window
| Action | Description |
|---|---|
bind_window |
Bind the current window to a slot |
activate_window |
Activate or minimize the window bound to a slot |
Clipboard
| Action | Description |
|---|---|
clipboard_copy |
Copy to a named clipboard slot |
clipboard_cut |
Cut to a named clipboard slot |
clipboard_paste |
Paste from a named clipboard slot |
clipboard_paste_plain |
Paste as plain text from a clipboard slot |
show_preferences
Open the Capslox preferences window.
Example:
"cx-shift-,": { "action": "show_preferences" }
edit_keymap
Open the built-in keymap editor.
Example:
"cx-shift-/": { "action": "edit_keymap" }
layer_push
Push a layer onto the stack. The layer deactivates automatically when the trigger key is released.
Parameters:
target(string) — layeridto push.
Example:
"cx-v": { "action": "layer_push", "target": "vim_normal" }
layer_pop
Pop a layer from the stack.
Parameters:
target(string, optional) — layeridto remove. Omit to pop the top; specify to remove that id from anywhere in the stack.
Example:
"i": { "action": "layer_pop", "target": "vim_normal" }
"esc": { "action": "layer_pop" }
layer_toggle
Toggle a layer: activate it if currently inactive, deactivate it if currently active.
Parameters:
target(string) — layeridto toggle.
Example:
"cx-t": { "action": "layer_toggle", "target": "symbols" }
layer_swap
Atomically remove the top non-base layer and push the target layer in its place. If only the base layer is active, swap pushes the target as a new top layer.
Parameters:
target(string) — layeridto push as the new top.
Example:
"cx-s": { "action": "layer_swap", "target": "numbers" }
shell_action
Run a shell command. Fire-and-forget — the command runs in the background without capturing output.
Parameters:
command(string) — shell command to run.
Example:
// ───── macOS (POSIX shell, /bin/sh) ─────
// Launch apps, URLs, folders
"cx-cmd-t": { "action": "shell_action", "command": "open -a Terminal" }
"cx-shift-c": { "action": "shell_action", "command": "open -a 'Google Chrome'" }
"cx-shift-g": { "action": "shell_action", "command": "open https://github.com" }
"cx-shift-h": { "action": "shell_action", "command": "open ~" }
"cx-shift-p": { "action": "shell_action", "command": "code ~/path/to/your/repo" }
// Read / write the clipboard
"cx-shift-s": { "action": "shell_action", "command": "open \"https://www.google.com/search?q=$(pbpaste)\"" }
"cx-shift-i": { "action": "shell_action", "command": "open \"$(pbpaste)\"" }
"cx-shift-d": { "action": "shell_action", "command": "date | pbcopy" }
// Screenshot, sleep, notify
"cx-shift-4": { "action": "shell_action", "command": "screencapture -i ~/Desktop/screen-$(date +%Y%m%d-%H%M%S).png" }
"cx-shift-l": { "action": "shell_action", "command": "pmset displaysleepnow" }
"cx-shift-b": { "action": "shell_action", "command": "osascript -e 'display notification \"Build done\" with title \"Capslox\"'" }
// Toggle macOS dark mode
"cx-shift-m": {
"action": "shell_action",
"command": "osascript -e 'tell app \"System Events\" to tell appearance preferences to set dark mode to not dark mode'"
}
// ───── Windows (cmd.exe) — escape backslashes as \\ in JSON ─────
// Launch URLs, folders, files (`start` opens the target with its default handler)
"cx-shift-g": { "action": "shell_action", "command": "start \"\" https://github.com" }
"cx-shift-h": { "action": "shell_action", "command": "explorer \"%USERPROFILE%\"" }
"cx-shift-i": { "action": "shell_action", "command": "start \"\" \"%USERPROFILE%\\Documents\\notes.md\"" }
"cx-shift-p": { "action": "shell_action", "command": "code \"%USERPROFILE%\\projects\\myrepo\"" }
// Copy a literal string to the clipboard (`clip` is built into Windows)
"cx-shift-c": { "action": "shell_action", "command": "echo Hello from Capslox| clip" }
// Lock the screen
"cx-shift-l": { "action": "shell_action", "command": "rundll32.exe user32.dll,LockWorkStation" }
How it works
| OS | Shell | Command |
|---|---|---|
| macOS | /bin/sh (login shell) |
/bin/sh -l -c "{command}" |
| Windows | cmd.exe |
cmd /C {command} |
- Runs from your home directory (
$HOMEon macOS,%USERPROFILE%on Windows). - On macOS,
/bin/shis a POSIX shell (typically bash in sh-compatibility mode on most systems). It does not use your default shell (e.g. fish, zsh) — it always invokes/bin/sh. - On Windows,
cmd.exeis not bash — it does not support bash's$(...)command substitution, POSIX glob expansion, or single-quote string syntax. If you need a pipeline that consumes a dynamic value (e.g. clipboard contents), call PowerShell explicitly:powershell -NoProfile -Command "<pipeline>". - PATH and environment:
- macOS:
/bin/sh -lreads.profile/.bash_profile. If your everyday shell is fish or zsh and you set PATH there, commands likenpm,cargo, orcodemay not be found byshell_actionunless they are also available in/bin/sh's PATH. - Windows:
cmd.exedoes not read any shell rc file. PATH comes from the System and User environment variables (Settings → "Edit the system environment variables"). After adding a tool to PATH, restart Capslox so the new value is picked up.
- macOS:
- No output is captured. Exit codes are logged internally (
ShellAction exited with code N) if the command fails. - Maximum one-shot: the command is spawned and forgotten. There is no way to kill or wait for it from Capslox.
Not suitable for interactive commands, long-running watchers, or commands that depend on a specific shell environment.
action_sequence
Execute multiple actions in order. Use this when no single action type covers what you need.
Parameters:
actions(array) — non-empty list of actions. Each element can be a string shorthand (resolves tosend_keys) or a full action object.
Example:
// Put the current date on the clipboard, then paste it.
"cx-shift-d": {
"action": "action_sequence",
"actions": [
{ "action": "shell_action", "command": "date | pbcopy" },
"cmd-v"
]
}
noop
Suppress the key. Nothing happens.
Example:
"cx-q": "noop"
Useful in overlay layers to block specific keys without letting them fall through. Also commonly used in when clauses to suppress a binding in specific apps where the default action has no meaningful equivalent.
send_keys
Send one or more key events. The most common action type.
Parameters:
keys(string) — a key syntax string. One or more combos, with optional repeat and grouping. See key syntax below.
Example:
// String shorthand (recommended — send_keys is the default action, so the whole object can be omitted)
"cx-h": "left"
"cx-cmd-a": "alt-left*5"
// Object form (required when you need a `when` clause)
"cx-a": {
"action": "send_keys",
"keys": "opt-left",
"when": { "apps": { "$ref": "/vars/apps_emacs_keybindings" }, "action": "ctrl-[ b" }
}
Key syntax
- Single combo: left, super-shift-4.
- Multi-combo (space-separated, executed in sequence): super-shift-left backspace.
- Repeat (
*Nafter a combo): alt-left*5. - Group repeat (
(combos)*N): (ctrl-[ b)*5.
Rules:
*Nwithout parens repeats the immediately preceding combo only.(combos)*Ngroups combos and repeats the entire group.- No nesting:
((...)...)*Nis invalid. - Maximum repeat: 100.
Common combos
Excerpted from the default keymap, ready to copy.
macOS:
// Cursor movement
"cx-e": "up"
"cx-d": "down"
"cx-s": "left"
"cx-f": "right"
"cx-a": "opt-left" // word back
"cx-g": "opt-right" // word forward
"cx-p": "cmd-left" // line start
"cx-;": "cmd-right" // line end
"cx-cmd-p": "cmd-up" // document start
"cx-cmd-;": "cmd-down" // document end
// Text selection
"cx-i": "shift-up" // select line up
"cx-k": "shift-down" // select line down
"cx-j": "shift-left" // select char left
"cx-l": "shift-right" // select char right
"cx-h": "shift-opt-left" // select word back
"cx-.": "shift-opt-right"// select word forward
"cx-u": "shift-cmd-left" // select to line start
"cx-o": "shift-cmd-right"// select to line end
// Deletion
"cx-w": "backspace" // delete char back
"cx-r": "delete" // delete char forward
"cx-cmd-w": "opt-backspace" // delete word back
"cx-cmd-r": "opt-delete" // delete word forward
Windows:
// Cursor movement
"cx-e": "up"
"cx-d": "down"
"cx-s": "left"
"cx-f": "right"
"cx-a": "ctrl-left" // word back
"cx-g": "ctrl-right" // word forward
"cx-p": "home" // line start
"cx-;": "end" // line end
"cx-alt-p": "ctrl-home" // document start
"cx-alt-;": "ctrl-end" // document end
// Text selection
"cx-i": "shift-up"
"cx-k": "shift-down"
"cx-j": "shift-left"
"cx-l": "shift-right"
"cx-h": "shift-ctrl-left" // select word back
"cx-.": "shift-ctrl-right" // select word forward
"cx-u": "shift-home" // select to line start
"cx-o": "shift-end" // select to line end
// Deletion
"cx-w": "backspace"
"cx-r": "delete"
"cx-alt-w": "ctrl-backspace" // delete word back
"cx-alt-r": "ctrl-delete" // delete word forward
toggle_caps_lock
Toggle the Caps Lock state of the OS.
Example:
"cx-shift-c": { "action": "toggle_caps_lock" }
Bind windows to named slots so you can instantly switch to (or minimize) a specific window.
bind_window
Bind the current window to a named slot.
Parameters:
slot(string) — window slot name.
Example:
"cx-cmd-1": { "action": "bind_window", "slot": "1" }
activate_window
Activate the window bound to a named slot. If the bound window is already the frontmost window, this action minimizes it instead (toggle behavior).
Parameters:
slot(string) — window slot name.
Example:
"cx-1": { "action": "activate_window", "slot": "1" }
Capslox provides named clipboard slots independent of the system clipboard. A slot name is any string.
clipboard_copy
Copy the current selection to a named clipboard slot.
Parameters:
slot(string) — clipboard slot name.
Example:
"cx-c": { "action": "clipboard_copy", "slot": "1" }
clipboard_cut
Cut the current selection to a named clipboard slot.
Parameters:
slot(string) — clipboard slot name.
Example:
"cx-x": { "action": "clipboard_cut", "slot": "1" }
clipboard_paste
Paste from a named clipboard slot, preserving formatting.
Parameters:
slot(string) — clipboard slot name.
Example:
"cx-v": { "action": "clipboard_paste", "slot": "1" }
clipboard_paste_plain
Paste from a named clipboard slot as plain text, stripping formatting.
Parameters:
slot(string) — clipboard slot name.
Example:
"cx-shift-v": { "action": "clipboard_paste_plain", "slot": "1" }
Key Vocabulary
Complete key vocabulary. All names are lowercase. These are used in binding keys, key sequence strings, and custom_modifiers.
Letters: a b c d e f g h i j k l m n o p q r s t u v w x y z
Digits: 0 1 2 3 4 5 6 7 8 9
Arrow keys: up down left right
Navigation: home end pageup pagedown
Function keys: f1 f2 f3 f4 f5 f6 f7 f8 f9 f10 f11 f12
Control keys
| Key | Description |
|---|---|
| space | Space bar |
| enter | Return / Enter |
| tab | Tab |
| esc | Escape |
| backspace | Delete backward (macOS: Delete key) |
| delete | Delete forward (macOS: Fn+Delete) |
| caps | Caps Lock |
Safe symbol keys
Use the literal character.
| Key | Character |
|---|---|
| [ | Left bracket |
| ] | Right bracket |
| / | Forward slash |
| = | Equals |
| , | Comma |
| . | Period |
| ; | Semicolon |
| ' | Apostrophe |
Named symbol keys
These three characters have special meaning in key syntax, so their physical keys use word names instead.
| Key name | Physical key | Why named |
|---|---|---|
| minus | - |
combo separator |
| backslash | \ |
JSON escape character |
| backtick | ` |
visually ambiguous |
Keypad keys
| Key | Description |
|---|---|
| kp_0 through kp_9 | Keypad digits |
| kp_enter | Keypad Enter |
| kp_plus | Keypad + |
| kp_minus | Keypad - |
| kp_multiply | Keypad * |
| kp_divide | Keypad / |
| kp_decimal | Keypad . |
| kp_equal | Keypad = |
| kp_clear | Keypad Clear |
Key syntax: Combine modifiers and keys with -:
"ctrl-shift-a" // Ctrl + Shift + A
"cmd-left" // Command + Left Arrow
"shift-/" // Shift + /
"cmd-minus" // Command + -
Modifier order: Modifiers can be written in any order — ctrl-shift-a and shift-ctrl-a are equivalent. The recommended order is: custom modifiers → caps → ctrl → alt → shift → cmd (following Apple HIG).
Modifiers
Modifier keys are used as prefixes in key syntax: modifier-key. Each platform has its own conventional names; both work, but prefer the name native to your platform — it reads more naturally for users on that platform:
| Modifier | macOS | Windows |
|---|---|---|
| Control | ctrl | ctrl |
| Shift | shift | shift |
| Option / Alt | opt | alt |
| Command / Win | cmd | win |
Append _left / _right to any modifier name to match a specific side — e.g. opt_left, cmd_right, ctrl_left. No suffix = either side.
"ctrl_right-a" // matches only Right Ctrl + A
"ctrl-a" // matches either side
Cross-platform alias super matches both macOS cmd and Windows win, useful for sharing keymaps across platforms.
Windows note: the OS reserves Win-key combinations (Win+R, Win+D, …) as system shortcuts. Per the Microsoft docs, keyboard shortcuts involving Win are reserved for the OS. So on Windows, prefer non-conflicting modifiers like alt over win in your shortcuts.
Custom Modifiers
Any physical key can become a modifier via custom_modifiers.
Fields:
alias— the modifier name used in bindings (e.g. cx-h).tap— action to fire on a quick tap-and-release. Accepts any action: a string shorthand (resolves tosend_keys, e.g."esc") or a full action object (required for engine-native actions liketoggle_caps_lock). Default: the key itself.timeout_ms— tap validity window in milliseconds (default: 200).
Three forms
To keep the syntax short, custom_modifiers supports the same kind of progressive omissions used elsewhere — all three forms below are accepted:
// Full form
"custom_modifiers": {
"caps": { "alias": "cx", "tap": { "action": "toggle_caps_lock" }, "timeout_ms": 300 }
}
// Omit defaults (equivalent to { "alias": "cx", "tap": "caps", "timeout_ms": 200 } — tapping Caps Lock sends Caps Lock)
"custom_modifiers": {
"caps": { "alias": "cx" }
}
// String shorthand (when you only need the alias — tap keeps the original key behavior) — recommended
"custom_modifiers": {
"caps": "cx"
}
Automatic mod_tap fallback
A key declared in custom_modifiers automatically picks up mod_tap behavior — hold for the modifier, tap for the configured tap action. You don't need a separate mod_tap binding for it; the redundancy is taken care of:
{
"custom_modifiers": { "caps": { "alias": "cx", "tap": "esc" } },
"layers": [{
"id": "base",
"on_miss": "pass_to_os",
"bindings": {
// No "caps": { "type": "mod_tap", ... } needed!
// Hold Caps = cx modifier, Tap Caps = Escape (from custom_modifiers)
"cx-h": "left",
"cx-j": "down"
}
}]
}
An explicit binding for the same key in some layer wins over the fallback.
Many-to-one aliases
Several physical keys can share one alias. The most practical case: the default keymap uses caps as the cx modifier, but compact keyboards like the HHKB have no Caps Lock key. Assigning tab to cx as well lets the keyboard inherit every existing binding without redoing the config:
"custom_modifiers": {
"caps": "cx",
"tab": "cx"
}
Now both caps and tab activate the cx modifier. cx-h fires whether you hold Caps or Tab.
The alias can be any name that follows the key naming convention (lowercase letters, digits, underscores). cx is the default by convention — short for Capslox.
Common Mistakes
Quick checks when a binding doesn't behave as expected:
- Physical key names, not Shift-produced characters — write shift-/ (not
?), shift-[ (not{), shift-1 (not!). Keys are named by their physical position on US QWERTY (see Key Vocabulary). - Three keys need word names — minus (not
-), backslash (not\), backtick (not`); each collides with the combo separator, JSON escape, or TS template literal. - Joining rule — use
-between a modifier and its key. cx-h means cx + h. - Engine-native actions need the object form —
toggle_caps_lock,layer_push,shell_action, and the clipboard/window actions can't be written as a bare string. String shorthand is reserved forsend_keys(any key syntax),"noop", and the three directive keywords"continue"/"block"/"pass_to_os".