Core Concepts
Understand key descriptors, sequences, prefixes, the Mod key, and how duck-vim processes keyboard input.
This page covers the fundamental patterns behind duck-vim. Understanding these concepts will make the entire API feel intuitive.
Key descriptors
A key descriptor is a string that represents a single key press, optionally combined with modifiers. The format is:
[modifier+]*key
Modifiers appear in alphabetical order: alt, ctrl, meta, shift. The key is always lowercase.
Examples:
| Input | Canonical form |
|---|---|
| Ctrl+K | ctrl+k |
| Shift+Alt+P | alt+shift+p |
| Mod+S (on Mac) | meta+s |
| Mod+S (on Windows) | ctrl+s |
| Space | space |
| Escape | esc |
duck-vim normalizes all bindings to canonical form internally.
Key aliases
Some keys have multiple names. duck-vim normalizes them:
| Alias | Canonical name |
|---|---|
(space character) | space |
escape | esc |
control | ctrl |
cmd, command | meta |
opt, option | alt |
You can use any alias when registering bindings. They all resolve to the same thing.
The Mod key
Mod is a virtual modifier that resolves based on the current platform. Write Mod+S once and it works correctly everywhere.
| Platform | Mod resolves to |
|---|---|
| macOS | meta (Cmd) |
| Windows | ctrl (Ctrl) |
| Linux | ctrl (Ctrl) |
Platform detection happens automatically via navigator.userAgent, with a linux fallback for SSR environments.
import { parseKeyBind } from '@gentleduck/vim/parser'
// On Mac:
parseKeyBind('Mod+S') // { key: 's', meta: true, ctrl: false, ... }
// On Windows:
parseKeyBind('Mod+S') // { key: 's', meta: false, ctrl: true, ... }import { parseKeyBind } from '@gentleduck/vim/parser'
// On Mac:
parseKeyBind('Mod+S') // { key: 's', meta: true, ctrl: false, ... }
// On Windows:
parseKeyBind('Mod+S') // { key: 's', meta: false, ctrl: true, ... }You can override the platform explicitly:
parseKeyBind('Mod+S', 'mac') // Always resolves to meta+s
parseKeyBind('Mod+S', 'linux') // Always resolves to ctrl+sparseKeyBind('Mod+S', 'mac') // Always resolves to meta+s
parseKeyBind('Mod+S', 'linux') // Always resolves to ctrl+sSequences
A sequence is two or more key presses in order, like Vim's g then d. duck-vim supports sequences in two ways:
When you register g+d in the Registry, the KeyHandler treats g and d as two separate key presses that must happen within the timeout window.
registry.register('g+d', { name: 'Go Dashboard', execute: () => {} })registry.register('g+d', { name: 'Go Dashboard', execute: () => {} })Internally, g is recognized as a prefix. When you press g, the handler waits for the next key. If d arrives before the timeout, the command fires. If the timeout expires, the sequence resets.
Prefixes
When you register g+d, the registry automatically marks g as a prefix. The KeyHandler uses this to decide whether to wait for more keys or discard the input.
Loading diagram...
The prefix check is instant (a Set lookup), so it doesn't add latency to normal typing.
Timeout
The timeout controls how long duck-vim waits between keys in a sequence. The default is 600ms.
- Too short (< 300ms): Users won't have time to press the second key.
- Too long (> 1000ms): The UI feels laggy because g is "swallowed" for too long.
- 600ms is a good default that matches Vim's behavior.
Configure it in the KeyHandler constructor or the React provider:
// Vanilla
const handler = new KeyHandler(registry, 800) // 800ms timeout
// React
<KeyProvider timeoutMs={800}>// Vanilla
const handler = new KeyHandler(registry, 800) // 800ms timeout
// React
<KeyProvider timeoutMs={800}>Input element handling
By default, key bindings fire even when the user is typing in an input field. This is usually not what you want for single-character bindings like g.
Set ignoreInputs: true to skip execution when the event target is a text input, textarea, select, or contenteditable element:
registry.register('g+d', command, { ignoreInputs: true })registry.register('g+d', command, { ignoreInputs: true })Button-type inputs (<input type="button">, <input type="submit">, <input type="reset">) are NOT considered text inputs, so bindings still fire when those are focused.
Event options
Each binding supports these options:
| Option | Default | Description |
|---|---|---|
enabled | true | Toggle the binding on/off without unregistering |
preventDefault | false | Call event.preventDefault() on match |
stopPropagation | false | Call event.stopPropagation() on match |
ignoreInputs | false | Skip when focus is in a text input |
requireReset | false | Fire only once until the handle's resetFired() is called |
conflictBehavior | 'warn' | What happens when a binding is already registered: 'warn', 'error', 'replace', or 'allow' |
How matching works
Build a key descriptor from the KeyboardEvent (modifiers + key, normalized)
Append it to the current sequence buffer
Check if the full buffer matches a registered command — if yes, execute and reset
Check if the full buffer is a prefix of any command — if yes, start the timeout and wait
If neither, reset the buffer and retry with just the current key (handles cases like pressing g, then x where x is a standalone binding)
Pure modifier key presses (Shift, Control, Alt, Meta alone) are always ignored.