This document provides a comprehensive reference for the skhd configuration syntax. The skhd.zig implementation is fully compatible with the original skhd syntax, with additional features.
The configuration syntax follows these formal rules:
hotkey = <mode> '<' <action> | <action>
mode = 'name of mode' | <mode> ',' <mode>
action = <trigger> '[' <proc_map_lst> ']' | <trigger> '->' '[' <proc_map_lst> ']'
<trigger> ':' <command> | <trigger> '->' ':' <command>
<trigger> ';' <mode_activation> | <trigger> '->' ';' <mode_activation>
<trigger> '~'
trigger = <keysym> | <keysym> ',' <keysym> (',' <keysym>)*
keysym = <mod> '-' <key> | <key>
mod = 'modifier keyword' | <mod> '+' <mod>
key = <literal> | <keycode>
literal = 'single letter or built-in keyword'
keycode = 'apple keyboard kVK_<Key> values (0x3C)'
proc_map_lst = * <proc_map>
proc_map = <string> ':' <command> | <string> '~' |
'*' ':' <command> | '*' '~' |
<string> ';' <mode_activation> |
'*' ';' <mode_activation> |
'@' <group_name> ':' <command> |
'@' <group_name> '~' |
'@' <group_name> ';' <mode_activation>
string = '"' 'sequence of characters' '"'
group_name = 'process group name defined with .define directive'
command = <shell_command> | <command_reference>
shell_command = command is executed through '$SHELL -c' and
follows valid shell syntax. if the $SHELL environment
variable is not set, it will default to '/bin/bash'.
when bash is used, the ';' delimiter can be specified
to chain commands.
to allow a command to extend into multiple lines,
prepend '\' at the end of the previous line.
an EOL character signifies the end of the bind.
command_reference = '@' <identifier> |
'@' <identifier> '(' <arg_list> ')'
arg_list = <string> | <string> ',' <arg_list>
mode_activation = <mode> | <mode> ':' <command>
-> = keypress is not consumed by skhd
* = matches every application not specified in <proc_map_lst>
~ = application is unbound and keypress is forwarded per usual
A sequence is not a separate construct — it is a hotkey whose trigger has
more than one chord. Every action form (:, |, ~, ->, ;, process
lists, process groups, command references, multi-mode declarations) works on
a sequence exactly as it does on a single-chord hotkey:
cmd - q, cmd - q : echo "double Cmd-Q"
cmd - k, cmd - c, alt - q : echo "three-chord sequence"
Every chord declares its own complete modifiers — nothing is inherited from the previous chord. Modifiers may be released and re-pressed between chords, as long as each chord's own modifiers are down when its key goes down.
.sequence_timeout 500ms # default 300ms; also accepts `500` or `1s`
Applies between each pair of chords, not to the sequence as a whole. Global, and must be greater than zero.
Like Vim's timeoutlen: a binding can be both a command and the prefix of a
longer one. If the sequence doesn't complete, the shorter one fires.
lcmd - k : yabai -m window --focus north
cmd - k, cmd - k [
"Google Chrome" | cmd - k
]
| press | in Chrome | elsewhere |
|---|---|---|
Cmd-K |
yabai, after the timeout | yabai, instantly |
Cmd-K Cmd-K |
Chrome's own Cmd-K | — |
Only Chrome waits: the sequence doesn't apply elsewhere, so there's nothing to wait for. That's how a global binding and an app's native shortcut share one chord.
Unlike Vim, skhd fires a declared binding, never the raw key. So a lone safety binding still swallows the first press:
cmd - q, cmd - q [
"Protected App" | cmd - q # nothing shorter -> one Cmd-Q does nothing
]
~ and -> can't be a fallback — the chord is consumed when the sequence
starts, so there's no keypress left to release. Parse error; use : or |.
An explicit rule claims its chord in whichever application it applies to. A chord with no applicable rule for the frontmost application is not consumed, so a sequence can express an application-specific safety binding while every other application keeps the operating system's normal behavior for that key:
cmd - q, cmd - q [
"Protected App" : echo "quit Protected App"
]
In Protected App, the first cmd-q starts the sequence. In every other
application this binding does not apply, so the first cmd-q is never
consumed and passes straight through to macOS.
Note: a : command reads to end of line, so the process-list body above
must be written on its own line(s) — [ "Protected App" : echo "..." ] on
one line would swallow the closing ] as part of the shell command and fail
to parse. A | forward has no such problem and is fine inline:
cmd - q, cmd - q [ "Protected App" | cmd - q ].
Earlier chords in a sequence are always consumed, because when an early
chord arrives it is not yet known whether the sequence will go on to
complete. Delivering it and also firing the action on completion would
send the application a keypress the user never triggered. So ->
(passthrough) and ~ (unbound) only affect the chord that completes the
binding:
cmd - k, cmd - c -> : echo "chord 1 is consumed; chord 2 fires and passes through"
cmd - k, cmd - u ~ # chord 1 is consumed; chord 2 is unbound and passes through
A single-chord hotkey is its own final chord, so this changes nothing for ordinary hotkeys.
Two hotkeys in a mode conflict when the same press could match both: identical chord lists always, and equal-length overlapping chords when their process scopes also overlap. Different lengths never conflict — the shorter is the longer's fallback.
# ERROR — identical chords.
cmd - a : echo first
cmd - a : echo second
# ERROR — identical chords. Disjoint apps do NOT help: hotkeys are keyed by
# chords alone, so the second could only be dropped. Use one process list.
cmd - a [
"Terminal" : echo terminal
]
cmd - a [
"Firefox" : echo firefox
]
# OK — same key, different modifiers (alt vs lalt), disjoint apps.
alt - a [
"Terminal" : echo terminal
]
lalt - a [
"Firefox" : echo firefox
]
# OK — different lengths: `echo now` is the fallback. Cmd-Q in Terminal runs
# it once the timeout expires; twice runs `echo later`.
cmd - q [
"Terminal" : echo now
]
cmd - q, cmd - q [
"Terminal" : echo later
]
# OK — shared prefix, disambiguated by the second chord.
cmd - k, cmd - c : echo comment
cmd - k, cmd - u : echo uncomment
A bare hotkey (no process list) has wildcard scope, which overlaps every explicit scope.
An expired or mismatched prefix is never replayed — its consumed key events
stay consumed, which matters for safety bindings like cmd-q where delayed
replay could quit an application unexpectedly.
Mode activation allows switching between different hotkey modes. The syntax is:
mode_activation = <mode> | <mode> ':' <command>
;followed by a mode name switches to that mode- An optional
:followed by a command executes that command when switching modes
cmd - w ; window- Switch to window modecmd - w ; window : echo "Window mode"- Switch to window mode and execute commandescape ; default- Switch back to default mode
Mode activation can be used in:
- Global hotkeys:
cmd - w ; window - Process-specific bindings:
"terminal" ; vim_mode - Process group bindings:
@browsers ; browser_mode
Modes are declared using the following syntax:
mode_decl = '::' <name> '@' ':' <command> | '::' <name> ':' <command> |
'::' <name> '@' | '::' <name>
name = desired name for this mode
@ = capture keypresses regardless of being bound to an action
command = command is executed through '$SHELL -c'
cmd- Command key (⌘)ctrl- Control key (⌃)alt- Alt/Option key (⌥)shift- Shift key (⇧)fn- Function key
lcmd,rcmd- Left/right Commandlctrl,rctrl- Left/right Controllalt,ralt- Left/right Altlshift,rshift- Left/right Shift
hyper- cmd + shift + alt + ctrlmeh- shift + alt + ctrl
left,right,up,down- Arrow keyshome,end- Home/End keyspageup,pagedown- Page Up/Down
return- Return/Enter keytab- Tab keyspace- Space barbackspace- Delete/Backspace (kVK_Delete)delete- Forward Delete (kVK_ForwardDelete)escape- Escape keybacktick- Backtick/Grave Accent key (`)
f1throughf20- Function keys
sound_up,sound_down- Volume controlsmute- Mute keybrightness_up,brightness_down- Screen brightnessillumination_up,illumination_down- Keyboard backlightplay,previous,next- Media playbackrewind,fast- Media navigation
mouse1- Left buttonmouse2- Right buttonmouse3- Middle buttonmouse4- Back / fourth buttonmouse5- Forward / fifth button
Used the same way as keys — combine with modifiers via -, or stand alone:
cmd - mouse1 : echo "cmd-click"
meh - mouse3 : open -a "Mission Control"
mouse4 -> : echo "back button" # passthrough: still goes to the appMouse buttons can also be the target of a forward, so you can synthesize a click from a key (e.g. inside a layer):
fn_layer < enter | mouse1 # in fn_layer, enter = left-click
fn_layer < space | cmd - mouse1 # cmd-click via the layermouse1 (or any mouse button) without a modifier and
without -> consumes every click in non-blacklisted apps and
effectively breaks the trackpad. Pair with a modifier (cmd - mouse1)
or use passthrough (mouse1 -> : ...) unless you really mean it.
Mouse-up and drag events are not captured (skhd only sees the down
edge); scroll-wheel events aren't bindable either.
Configuration directives follow this syntax:
directive = '.shell' <string> |
'.blacklist' '[' <string_list> ']' |
'.load' <string> |
'.sequence_timeout' <duration> |
'.path' <string> | '.path' '[' <string_list> ']' |
'.define' <identifier> '[' <string_list> ']' |
'.define' <identifier> ':' <command_template> |
'.device' <identifier> '{' <device_attrs> '}' |
'.remap' <hid_key> <device_clause> ':' <hid_key> |
'.remap' <hid_key> <device_clause> '{' <taphold_attrs> '}'
device_attrs = 'vendor:' <hex> ',' 'product:' <hex>
device_clause = '[' 'device' <identifier> ']'
taphold_attrs = ( <taphold_attr> )+
taphold_attr = 'tap' ':' <hid_key>
| 'hold' ':' <hid_key_or_layer>
| 'timeout' ':' <duration>
| 'permissive_hold' ':' ('on' | 'off')
| 'hold_on_other_key_press' ':' ('on' | 'off')
| 'retro_tap' ':' ('on' | 'off')
hid_key_or_layer = <hid_key> | <mode_identifier> // mode = layer hold
duration = <integer> ('ms' | 's')? // bare integer = milliseconds.
// The unit must be on the same
// line as the integer.
string_list = <string> | <string> ',' <string_list>
.remap / .taphold / .device operate at the HID layer, before
macOS translates keys through the active layout. They use HID-standard
layout-independent physical-position names (caps_lock, lctrl,
non_us_backslash, a–z, 0–9, f1–f20, minus, equal,
lbracket, rbracket, backslash, semicolon, quote, grave,
comma, period, slash, space, return, tab, escape,
backspace, etc.) — different from the macOS virtual-keycode names
(0x32, 0x29, etc.) that the regular cmd - a hotkey table uses.
Run skhd --grabber-status once installed (or check
src/HidKeyMap.zig) for the full list. These are the same identifiers
Karabiner-Elements uses, so its docs
work as a cross-reference.
.shell "/bin/zsh".blacklist [
"loginwindow"
"screensaver"
"VMware Fusion"
].load "~/.config/skhd/extra.skhdrc"At startup skhd inherits PATH from the user's login shell (~/.zprofile,
~/.bash_profile, fish's config.fish, etc.) so commands installed by
Homebrew and similar work out of the box. For tools whose location isn't in
the shell's PATH — most commonly version-manager shims like mise/asdf/nvm —
declare the directory with .path:
.path "$HOME/.local/share/mise/shims"
.path "~/.cargo/bin"
# Or list form:
.path [
"/opt/custom/bin"
"$HOME/bin"
].path entries are prepended to PATH (declaration order preserved), so they
take precedence over shell-inherited locations. ~ and $HOME are
expanded; other $VAR forms are not — use absolute paths for everything
else.
Give a name to a modifier combination or to a single key. The name is
referenced with a $ prefix, must start with a letter, and is expanded
at parse time (zero runtime cost).
.alias $hyper cmd + alt + ctrl + shift
.alias $super cmd + alt
# Use as the modifier prefix of a hotkey
$hyper - h : echo "hyper-h"
$super - return : open -a Terminal.app
# Combine with other modifiers via '+'
$super + shift - h : echo "super+shift+h"
# A modifier alias may reference an earlier modifier alias
.alias $mega $super + shift + ctrl.alias $grave 0x32 # Hex keycode (e.g., UK keyboard backtick)
.alias $del delete # Literal key (carries any implicit fn/nx flag)
# Use after the dash, or standalone
ctrl - $grave : open -a Notes
$del : echo plain-delete
# A key alias may reference another key alias
.alias $tilde $grave- Aliases must be defined before use; redefinition is an error.
- A modifier alias appears in modifier position only (before
-, or chained with+). Using it as a key (ctrl - $hyper) is an error. - A key alias appears in key position only (after
-, or standalone). Using it as a modifier ($grave - h) is an error. - Combining modifiers into a baked-in keysym (e.g.
.alias $foo cmd - h) is not supported — define the modifier and key parts separately and combine them at the use site.
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define browser_apps ["chrome", "safari", "firefox"]
# Use with @ prefix in proc_map
ctrl - left [
@terminal_apps ~
* | alt - left
]# Simple command without placeholders
.define focus_recent : yabai -m window --focus recent
# Command with placeholders
.define yabai_focus : yabai -m window --focus {{1}} || yabai -m display --focus {{1}}
.define window_action : yabai -m window --{{1}} {{2}}
# Use with @ prefix and arguments
cmd - tab : @focus_recent
cmd - h : @yabai_focus("west")
cmd + shift - h : @window_action("swap", "west")Declare a USB keyboard once by vendor/product ID, then reference it
by alias from .remap / .taphold rules. The alias is a config-local
name — pick anything (builtin, corsair, keychron, …). Find your
keyboard's IDs via System Information → USB, or run any .remap rule
with verbose mode to see currently-attached vendor/product pairs in
the log.
.device builtin { vendor: 0x05AC, product: 0x0342 }
.device keychron { vendor: 0x05AC, product: 0x024F }A config shared between machines targeting different hardware is fine —
rules whose [device <alias>] doesn't match a connected device are
silently skipped on that machine. No grabber is installed on a
machine without any matching device.
Instant 1:1 key swap, applied via hidutil's UserKeyMapping table.
No daemon needed for the colon form — works without installing
skhd-grabber. Original mappings are saved on startup and restored on
shutdown so the keyboard isn't left remapped when skhd exits.
# UK ISO MacBook: map § (top-left key, HID-named non_us_backslash) to
# the ISO grave key so it types `.
.remap non_us_backslash [device builtin] : grave
# Swap caps_lock with escape on an external keyboard.
.remap caps_lock [device keychron] : escapeLimitations (use the block form below instead for these cases):
- Cannot map
caps_lockto a modifier — macOS's kernel layer abovehidutilsilently dropscaps_lock → ctrl/shift/alt/cmd. - Cannot do tap-hold or layer-hold semantics.
Distinguish tap vs. hold timing on the same physical key, plus layer
holds. Routed through skhd-grabber (root LaunchDaemon) — see
skhd-grabber in the
README for install + permission setup.
.remap caps_lock [device builtin] {
tap : escape
hold : lctrl
timeout : 120ms
permissive_hold : on
retro_tap : off
}Attributes — names, semantics, and defaults all follow
QMK firmware's tap-hold model
(snake_case keywords, same parameter set as a QMK config.h).
We deliberately don't use Karabiner-Elements' complex-modifications
JSON dialect — skhd users want a config that reads like the rest of
.skhdrc, not a separate verbose camelCase format.
| Attribute | Type | Default | QMK equivalent | Description |
|---|---|---|---|---|
tap |
hid_key | required | LT(layer, kc) tap behavior |
Key emitted on a quick tap (press + release within timeout). |
hold |
hid_key or mode | required | LT(layer, kc) hold behavior |
Key emitted while held past timeout. A mode identifier here makes it a layer hold (see below). |
timeout |
duration | 200ms |
TAPPING_TERM |
How long the source key has to be held to commit the hold action. |
permissive_hold |
on/off |
on |
PERMISSIVE_HOLD |
If on, a nested down+up of another key while the source is held also commits the hold modifier. Useful for typing Ctrl+A by holding caps for ~50ms then quickly tapping a. |
hold_on_other_key_press |
on/off |
off |
HOLD_ON_OTHER_KEY_PRESS |
If on, any other key pressed (even without release) while the source is held immediately commits the hold action. Stricter than permissive_hold. |
retro_tap |
on/off |
off |
RETRO_TAPPING |
If on, releasing the source key without committing a hold still emits the tap key. Useful for over-held keys (e.g. holding space then releasing without pressing anything else still types a space). |
When hold references a mode identifier instead of a key, the
source key acts as a temporary layer activator: hold to enter the
mode, release to exit. Layer hold rules push IPC messages from grabber
→ agent so the mode change happens on the agent's run loop (where
mode bindings are evaluated).
# Declare a capture-mode for unbound keys not to leak through.
:: fn_layer @
# Hold space → enter fn_layer; release → back to default.
.remap space [device builtin] {
tap : space
hold : fn_layer
timeout : 200ms
retro_tap : on
}
# While the layer is held, number row maps to F-row.
fn_layer < 1 | f1
fn_layer < 2 | f2
fn_layer < tab | alt - tab # cmd-tab style app switcher
fn_layer < 0x1B | f11 # virtual keycodes also work in layerLayer-hold modes use the same :: <name> @ declaration syntax as
regular modes — capture (@) decides whether
unbound keys in the layer leak through to the focused app or are
absorbed.
cmd - a : echo "Command+A pressed"cmd + shift + alt - x : echo "Complex hotkey"cmd - n [
"terminal" : echo "New terminal window"
"safari" : echo "New browser window"
* : echo "New window in other apps"
]# Simple forwarding
ctrl - h | left
# Process-specific forwarding
home [
"kitty" ~ # Let kitty handle it
* | cmd - left # In other apps, send Cmd+Left
]# Declare mode
:: window : echo "Window mode"
# Enter mode
cmd - w ; window
# Enter mode and execute command
cmd - w ; window : echo "Switching to window mode"
# Commands in mode
window < h : yabai -m window --focus west
window < escape ; default : echo "Returning to default mode"Mode activation can also be used in process lists, allowing different applications to trigger different modes:
# Define terminal and browser app groups
.define terminal_apps ["kitty", "wezterm", "terminal"]
.define browser_apps ["chrome", "safari", "firefox"]
# Different apps switch to different modes with Cmd+M
cmd - m [
@terminal_apps ; vim_mode : echo "Vim mode for terminals"
@browser_apps ; browser_mode : echo "Browser mode activated"
* ; default : echo "Back to default"
]
# Mode activation with command in process list
cmd - e [
"code" ; edit_mode : osascript -e 'display notification "Edit mode for VS Code"'
"xcode" ; edit_mode : osascript -e 'display notification "Edit mode for Xcode"'
* : echo "No special mode for this app"
]# Execute command but still send keypress
cmd - p -> : echo "Command runs but Cmd+P goes to app"cmd - x : echo "Line 1" ; \
echo "Line 2" ; \
echo "Line 3"- Comments: Use
#for comments - Unbinding: Use
~to unbind a key in specific applications - Wildcard: Use
*to match all applications not explicitly specified - Keycode: Use hex values like
0x3Cfor specific key codes - Mode Capture: Use
@after mode name to capture all keypresses
# Global navigation
cmd - h : focus west
cmd - j : focus south
cmd - k : focus north
cmd - l : focus eastalt - return : open -a Terminal
alt - b : open -a Safari:: resize @ : echo "Resize mode"
cmd - r ; resize
resize < h : resize left
resize < l : resize right
resize < escape ; default# Word movement
ctrl - left [
@terminal_apps ~
* | alt - left
]
# Line start/end
home [
@native_apps ~
* | cmd - left
]